hcom 0.2.0__py3-none-any.whl → 0.2.2__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.
Potentially problematic release.
This version of hcom might be problematic. Click here for more details.
- hcom/__init__.py +1 -1
- hcom/__main__.py +1741 -1049
- hcom-0.2.2.dist-info/METADATA +449 -0
- hcom-0.2.2.dist-info/RECORD +7 -0
- hcom-0.2.0.dist-info/METADATA +0 -348
- hcom-0.2.0.dist-info/RECORD +0 -7
- {hcom-0.2.0.dist-info → hcom-0.2.2.dist-info}/WHEEL +0 -0
- {hcom-0.2.0.dist-info → hcom-0.2.2.dist-info}/entry_points.txt +0 -0
- {hcom-0.2.0.dist-info → hcom-0.2.2.dist-info}/top_level.txt +0 -0
hcom/__main__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
hcom
|
|
4
|
-
|
|
3
|
+
hcom 0.2.2
|
|
4
|
+
CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
@@ -14,7 +14,6 @@ import re
|
|
|
14
14
|
import subprocess
|
|
15
15
|
import time
|
|
16
16
|
import select
|
|
17
|
-
import threading
|
|
18
17
|
import platform
|
|
19
18
|
import random
|
|
20
19
|
from pathlib import Path
|
|
@@ -24,6 +23,25 @@ from datetime import datetime, timedelta
|
|
|
24
23
|
|
|
25
24
|
IS_WINDOWS = sys.platform == 'win32'
|
|
26
25
|
|
|
26
|
+
def is_wsl():
|
|
27
|
+
"""Detect if running in WSL (Windows Subsystem for Linux)"""
|
|
28
|
+
if platform.system() != 'Linux':
|
|
29
|
+
return False
|
|
30
|
+
try:
|
|
31
|
+
with open('/proc/version', 'r') as f:
|
|
32
|
+
return 'microsoft' in f.read().lower()
|
|
33
|
+
except:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def is_termux():
|
|
37
|
+
"""Detect if running in Termux on Android"""
|
|
38
|
+
return (
|
|
39
|
+
'TERMUX_VERSION' in os.environ or # Primary: Works all versions
|
|
40
|
+
'TERMUX__ROOTFS' in os.environ or # Modern: v0.119.0+
|
|
41
|
+
os.path.exists('/data/data/com.termux') or # Fallback: Path check
|
|
42
|
+
'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
|
|
43
|
+
)
|
|
44
|
+
|
|
27
45
|
HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
|
|
28
46
|
HCOM_ACTIVE_VALUE = '1'
|
|
29
47
|
|
|
@@ -33,16 +51,55 @@ EXIT_BLOCK = 2
|
|
|
33
51
|
|
|
34
52
|
HOOK_DECISION_BLOCK = 'block'
|
|
35
53
|
|
|
54
|
+
ERROR_ACCESS_DENIED = 5 # Windows - Process exists but no permission
|
|
55
|
+
ERROR_INVALID_PARAMETER = 87 # Windows - Invalid PID or parameters
|
|
56
|
+
ERROR_ALREADY_EXISTS = 183 # Windows - For file/mutex creation, not process checks
|
|
57
|
+
|
|
58
|
+
# Windows API constants
|
|
59
|
+
DETACHED_PROCESS = 0x00000008 # CreateProcess flag for no console window
|
|
60
|
+
CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
|
|
61
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 # Vista+ minimal access rights
|
|
62
|
+
PROCESS_QUERY_INFORMATION = 0x0400 # Pre-Vista process access rights TODO: is this a joke? why am i supporting pre vista? who the fuck is running claude code on vista let alone pre?!
|
|
63
|
+
|
|
64
|
+
# Timing constants
|
|
65
|
+
FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
|
|
66
|
+
STOP_HOOK_POLL_INTERVAL = 0.05 # 50ms between stop hook polls
|
|
67
|
+
KILL_CHECK_INTERVAL = 0.1 # 100ms between process termination checks
|
|
68
|
+
|
|
69
|
+
# Windows kernel32 cache
|
|
70
|
+
_windows_kernel32_cache = None
|
|
71
|
+
|
|
72
|
+
def get_windows_kernel32():
|
|
73
|
+
"""Get cached Windows kernel32 with function signatures configured.
|
|
74
|
+
This eliminates repeated initialization in hot code paths (e.g., stop hook polling).
|
|
75
|
+
"""
|
|
76
|
+
global _windows_kernel32_cache
|
|
77
|
+
if _windows_kernel32_cache is None and IS_WINDOWS:
|
|
78
|
+
import ctypes
|
|
79
|
+
import ctypes.wintypes
|
|
80
|
+
kernel32 = ctypes.windll.kernel32
|
|
81
|
+
|
|
82
|
+
# Set proper ctypes function signatures to avoid ERROR_INVALID_PARAMETER
|
|
83
|
+
kernel32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
|
|
84
|
+
kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
|
|
85
|
+
kernel32.GetLastError.argtypes = []
|
|
86
|
+
kernel32.GetLastError.restype = ctypes.wintypes.DWORD
|
|
87
|
+
kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
|
|
88
|
+
kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
|
|
89
|
+
kernel32.GetExitCodeProcess.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(ctypes.wintypes.DWORD)]
|
|
90
|
+
kernel32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL
|
|
91
|
+
|
|
92
|
+
_windows_kernel32_cache = kernel32
|
|
93
|
+
return _windows_kernel32_cache
|
|
94
|
+
|
|
36
95
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
37
96
|
TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
|
|
38
97
|
|
|
39
98
|
RESET = "\033[0m"
|
|
40
99
|
DIM = "\033[2m"
|
|
41
100
|
BOLD = "\033[1m"
|
|
42
|
-
FG_BLUE = "\033[34m"
|
|
43
101
|
FG_GREEN = "\033[32m"
|
|
44
102
|
FG_CYAN = "\033[36m"
|
|
45
|
-
FG_RED = "\033[31m"
|
|
46
103
|
FG_WHITE = "\033[37m"
|
|
47
104
|
FG_BLACK = "\033[30m"
|
|
48
105
|
BG_BLUE = "\033[44m"
|
|
@@ -60,6 +117,23 @@ STATUS_MAP = {
|
|
|
60
117
|
"inactive": (BG_RED, "○")
|
|
61
118
|
}
|
|
62
119
|
|
|
120
|
+
# ==================== Windows/WSL Console Unicode ====================
|
|
121
|
+
import io
|
|
122
|
+
|
|
123
|
+
# Apply UTF-8 encoding for Windows and WSL
|
|
124
|
+
if IS_WINDOWS or is_wsl():
|
|
125
|
+
try:
|
|
126
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
127
|
+
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
|
128
|
+
except:
|
|
129
|
+
pass # Fallback if stream redirection fails
|
|
130
|
+
|
|
131
|
+
# ==================== Error Handling Strategy ====================
|
|
132
|
+
# Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
|
|
133
|
+
# CLI: Can raise exceptions for user feedback. Check return values.
|
|
134
|
+
# Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
|
|
135
|
+
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
136
|
+
|
|
63
137
|
# ==================== Configuration ====================
|
|
64
138
|
|
|
65
139
|
DEFAULT_CONFIG = {
|
|
@@ -69,12 +143,13 @@ DEFAULT_CONFIG = {
|
|
|
69
143
|
"sender_name": "bigboss",
|
|
70
144
|
"sender_emoji": "🐳",
|
|
71
145
|
"cli_hints": "",
|
|
72
|
-
"wait_timeout": 1800,
|
|
73
|
-
"max_message_size":
|
|
146
|
+
"wait_timeout": 1800, # 30mins
|
|
147
|
+
"max_message_size": 1048576, # 1MB
|
|
74
148
|
"max_messages_per_delivery": 50,
|
|
75
149
|
"first_use_text": "Essential, concise messages only, say hi in hcom chat now",
|
|
76
150
|
"instance_hints": "",
|
|
77
|
-
"env_overrides": {}
|
|
151
|
+
"env_overrides": {},
|
|
152
|
+
"auto_watch": True # Auto-launch watch dashboard after open
|
|
78
153
|
}
|
|
79
154
|
|
|
80
155
|
_config = None
|
|
@@ -90,74 +165,171 @@ HOOK_SETTINGS = {
|
|
|
90
165
|
'cli_hints': 'HCOM_CLI_HINTS',
|
|
91
166
|
'terminal_mode': 'HCOM_TERMINAL_MODE',
|
|
92
167
|
'terminal_command': 'HCOM_TERMINAL_COMMAND',
|
|
93
|
-
'initial_prompt': 'HCOM_INITIAL_PROMPT'
|
|
168
|
+
'initial_prompt': 'HCOM_INITIAL_PROMPT',
|
|
169
|
+
'auto_watch': 'HCOM_AUTO_WATCH'
|
|
94
170
|
}
|
|
95
171
|
|
|
96
|
-
#
|
|
172
|
+
# Path constants
|
|
173
|
+
LOG_FILE = "hcom.log"
|
|
174
|
+
INSTANCES_DIR = "instances"
|
|
175
|
+
LOGS_DIR = "logs"
|
|
176
|
+
SCRIPTS_DIR = "scripts"
|
|
177
|
+
CONFIG_FILE = "config.json"
|
|
178
|
+
ARCHIVE_DIR = "archive"
|
|
97
179
|
|
|
98
|
-
|
|
99
|
-
"""Get the hcom directory in user's home"""
|
|
100
|
-
return Path.home() / ".hcom"
|
|
180
|
+
# ==================== File System Utilities ====================
|
|
101
181
|
|
|
102
|
-
def
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
182
|
+
def hcom_path(*parts, ensure_parent=False):
|
|
183
|
+
"""Build path under ~/.hcom"""
|
|
184
|
+
path = Path.home() / ".hcom"
|
|
185
|
+
if parts:
|
|
186
|
+
path = path.joinpath(*parts)
|
|
187
|
+
if ensure_parent:
|
|
188
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
return path
|
|
107
190
|
|
|
108
191
|
def atomic_write(filepath, content):
|
|
109
|
-
"""Write content to file atomically to prevent corruption"""
|
|
110
|
-
filepath = Path(filepath)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
192
|
+
"""Write content to file atomically to prevent corruption (now with NEW and IMPROVED (wow!) Windows retry logic (cool!)). Returns True on success, False on failure."""
|
|
193
|
+
filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
|
|
194
|
+
|
|
195
|
+
for attempt in range(3):
|
|
196
|
+
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
|
|
197
|
+
tmp.write(content)
|
|
198
|
+
tmp.flush()
|
|
199
|
+
os.fsync(tmp.fileno())
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
os.replace(tmp.name, filepath)
|
|
203
|
+
return True
|
|
204
|
+
except PermissionError:
|
|
205
|
+
if IS_WINDOWS and attempt < 2:
|
|
206
|
+
time.sleep(FILE_RETRY_DELAY)
|
|
207
|
+
continue
|
|
208
|
+
else:
|
|
209
|
+
try: # Clean up temp file on final failure
|
|
210
|
+
os.unlink(tmp.name)
|
|
211
|
+
except:
|
|
212
|
+
pass
|
|
213
|
+
return False
|
|
214
|
+
except Exception:
|
|
215
|
+
try: # Clean up temp file on any other error
|
|
216
|
+
os.unlink(tmp.name)
|
|
217
|
+
except:
|
|
218
|
+
pass
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
|
|
222
|
+
"""Read file with retry logic for Windows file locking"""
|
|
223
|
+
if not Path(filepath).exists():
|
|
224
|
+
return default
|
|
225
|
+
|
|
226
|
+
for attempt in range(max_retries):
|
|
227
|
+
try:
|
|
228
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
229
|
+
return read_func(f)
|
|
230
|
+
except PermissionError as e:
|
|
231
|
+
# Only retry on Windows (file locking issue)
|
|
232
|
+
if IS_WINDOWS and attempt < max_retries - 1:
|
|
233
|
+
time.sleep(FILE_RETRY_DELAY)
|
|
234
|
+
else:
|
|
235
|
+
# Re-raise on Unix or after max retries on Windows
|
|
236
|
+
if not IS_WINDOWS:
|
|
237
|
+
raise # Unix permission errors are real issues
|
|
238
|
+
break # Windows: return default after retries
|
|
239
|
+
except (json.JSONDecodeError, FileNotFoundError, IOError):
|
|
240
|
+
break # Don't retry on other errors
|
|
241
|
+
|
|
242
|
+
return default
|
|
117
243
|
|
|
118
244
|
def get_instance_file(instance_name):
|
|
119
245
|
"""Get path to instance's position file"""
|
|
120
|
-
return
|
|
246
|
+
return hcom_path(INSTANCES_DIR, f"{instance_name}.json")
|
|
247
|
+
|
|
248
|
+
def migrate_instance_data_v020(data, instance_name):
|
|
249
|
+
"""One-time migration from v0.2.0 format (remove in v0.3.0)"""
|
|
250
|
+
needs_save = False
|
|
251
|
+
|
|
252
|
+
# Convert single session_id to session_ids array
|
|
253
|
+
if 'session_ids' not in data and 'session_id' in data and data['session_id']:
|
|
254
|
+
data['session_ids'] = [data['session_id']]
|
|
255
|
+
needs_save = True
|
|
256
|
+
|
|
257
|
+
# Remove conversation_uuid - no longer used anywhere
|
|
258
|
+
if 'conversation_uuid' in data:
|
|
259
|
+
del data['conversation_uuid']
|
|
260
|
+
needs_save = True
|
|
261
|
+
|
|
262
|
+
if needs_save:
|
|
263
|
+
save_instance_position(instance_name, data)
|
|
264
|
+
|
|
265
|
+
return data
|
|
121
266
|
|
|
122
267
|
def load_instance_position(instance_name):
|
|
123
268
|
"""Load position data for a single instance"""
|
|
124
269
|
instance_file = get_instance_file(instance_name)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
270
|
+
|
|
271
|
+
data = read_file_with_retry(
|
|
272
|
+
instance_file,
|
|
273
|
+
lambda f: json.load(f),
|
|
274
|
+
default={}
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Apply migration if needed
|
|
278
|
+
if data:
|
|
279
|
+
data = migrate_instance_data_v020(data, instance_name)
|
|
280
|
+
|
|
281
|
+
return data
|
|
132
282
|
|
|
133
283
|
def save_instance_position(instance_name, data):
|
|
134
|
-
"""Save position data for a single instance
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
284
|
+
"""Save position data for a single instance. Returns True on success, False on failure."""
|
|
285
|
+
try:
|
|
286
|
+
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json", ensure_parent=True)
|
|
287
|
+
return atomic_write(instance_file, json.dumps(data, indent=2))
|
|
288
|
+
except:
|
|
289
|
+
return False
|
|
138
290
|
|
|
139
291
|
def load_all_positions():
|
|
140
292
|
"""Load positions from all instance files"""
|
|
141
|
-
instances_dir =
|
|
293
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
142
294
|
if not instances_dir.exists():
|
|
143
295
|
return {}
|
|
144
|
-
|
|
296
|
+
|
|
145
297
|
positions = {}
|
|
146
298
|
for instance_file in instances_dir.glob("*.json"):
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
299
|
+
instance_name = instance_file.stem
|
|
300
|
+
data = read_file_with_retry(
|
|
301
|
+
instance_file,
|
|
302
|
+
lambda f: json.load(f),
|
|
303
|
+
default={}
|
|
304
|
+
)
|
|
305
|
+
if data:
|
|
306
|
+
positions[instance_name] = data
|
|
153
307
|
return positions
|
|
154
308
|
|
|
155
309
|
def clear_all_positions():
|
|
156
310
|
"""Clear all instance position files"""
|
|
157
|
-
instances_dir =
|
|
311
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
158
312
|
if instances_dir.exists():
|
|
159
|
-
|
|
160
|
-
|
|
313
|
+
for f in instances_dir.glob('*.json'):
|
|
314
|
+
f.unlink()
|
|
315
|
+
else:
|
|
316
|
+
instances_dir.mkdir(exist_ok=True)
|
|
317
|
+
|
|
318
|
+
# ==================== Hook Utilities ====================
|
|
319
|
+
|
|
320
|
+
HCOM_FORMAT_INSTRUCTIONS = """\n\nREQUIRED: Display HCOM 'reason' content using this format with markdown:
|
|
321
|
+
═════════ HCOM ═════════
|
|
322
|
+
***[✓ Sent] [new message] and/or [all other info in square brackets]***
|
|
323
|
+
**name → name:** *message content*
|
|
324
|
+
════════════════════════
|
|
325
|
+
Always show exact full content of every single message without saying why you are displaying it. This format is mandatory for system functionality"""
|
|
326
|
+
|
|
327
|
+
def emit_hook_response(reason, decision=HOOK_DECISION_BLOCK, exit_code=EXIT_BLOCK):
|
|
328
|
+
"""Emit standardized hook JSON response"""
|
|
329
|
+
output = {"decision": decision, "reason": reason} if decision else {"reason": reason}
|
|
330
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
331
|
+
sys.exit(exit_code)
|
|
332
|
+
|
|
161
333
|
|
|
162
334
|
# ==================== Configuration System ====================
|
|
163
335
|
|
|
@@ -170,28 +342,28 @@ def get_cached_config():
|
|
|
170
342
|
|
|
171
343
|
def _load_config_from_file():
|
|
172
344
|
"""Actually load configuration from ~/.hcom/config.json"""
|
|
173
|
-
|
|
174
|
-
config_path =
|
|
175
|
-
|
|
176
|
-
config =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
345
|
+
import copy
|
|
346
|
+
config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
|
|
347
|
+
|
|
348
|
+
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
user_config = read_file_with_retry(
|
|
352
|
+
config_path,
|
|
353
|
+
lambda f: json.load(f),
|
|
354
|
+
default=None
|
|
355
|
+
)
|
|
356
|
+
if user_config:
|
|
357
|
+
for key, value in user_config.items():
|
|
358
|
+
if key == 'env_overrides':
|
|
359
|
+
config['env_overrides'].update(value)
|
|
360
|
+
else:
|
|
361
|
+
config[key] = value
|
|
362
|
+
elif not config_path.exists():
|
|
363
|
+
atomic_write(config_path, json.dumps(DEFAULT_CONFIG, indent=2))
|
|
364
|
+
except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
|
|
365
|
+
print("Warning: Cannot read config file, using defaults", file=sys.stderr)
|
|
366
|
+
|
|
195
367
|
return config
|
|
196
368
|
|
|
197
369
|
def get_config_value(key, default=None):
|
|
@@ -209,9 +381,11 @@ def get_config_value(key, default=None):
|
|
|
209
381
|
return int(env_value)
|
|
210
382
|
except ValueError:
|
|
211
383
|
pass
|
|
384
|
+
elif key == 'auto_watch': # Convert string to boolean
|
|
385
|
+
return env_value.lower() in ('true', '1', 'yes', 'on')
|
|
212
386
|
else:
|
|
213
387
|
return env_value
|
|
214
|
-
|
|
388
|
+
|
|
215
389
|
config = get_cached_config()
|
|
216
390
|
return config.get(key, default)
|
|
217
391
|
|
|
@@ -224,13 +398,18 @@ def get_hook_command():
|
|
|
224
398
|
python_path = sys.executable
|
|
225
399
|
script_path = os.path.abspath(__file__)
|
|
226
400
|
|
|
227
|
-
if
|
|
228
|
-
#
|
|
401
|
+
if IS_WINDOWS:
|
|
402
|
+
# Windows cmd.exe syntax - no parentheses so arguments append correctly
|
|
403
|
+
if ' ' in python_path or ' ' in script_path:
|
|
404
|
+
return f'IF "%HCOM_ACTIVE%"=="1" "{python_path}" "{script_path}"', {}
|
|
405
|
+
return f'IF "%HCOM_ACTIVE%"=="1" {python_path} {script_path}', {}
|
|
406
|
+
elif ' ' in python_path or ' ' in script_path:
|
|
407
|
+
# Unix with spaces: use conditional check
|
|
229
408
|
escaped_python = shlex.quote(python_path)
|
|
230
409
|
escaped_script = shlex.quote(script_path)
|
|
231
410
|
return f'[ "${{HCOM_ACTIVE}}" = "1" ] && {escaped_python} {escaped_script} || true', {}
|
|
232
411
|
else:
|
|
233
|
-
#
|
|
412
|
+
# Unix clean paths: use environment variable
|
|
234
413
|
return '${HCOM:-true}', {}
|
|
235
414
|
|
|
236
415
|
def build_claude_env():
|
|
@@ -266,32 +445,21 @@ def validate_message(message):
|
|
|
266
445
|
"""Validate message size and content"""
|
|
267
446
|
if not message or not message.strip():
|
|
268
447
|
return format_error("Message required")
|
|
269
|
-
|
|
270
|
-
|
|
448
|
+
|
|
449
|
+
# Reject control characters (except \n, \r, \t)
|
|
450
|
+
if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
|
|
451
|
+
return format_error("Message contains control characters")
|
|
452
|
+
|
|
453
|
+
max_size = get_config_value('max_message_size', 1048576)
|
|
271
454
|
if len(message) > max_size:
|
|
272
455
|
return format_error(f"Message too large (max {max_size} chars)")
|
|
273
|
-
|
|
274
|
-
return None
|
|
275
456
|
|
|
276
|
-
|
|
277
|
-
"""Check argument count and exit with usage if insufficient"""
|
|
278
|
-
if len(sys.argv) < min_count:
|
|
279
|
-
print(f"Usage: {usage_msg}")
|
|
280
|
-
if extra_msg:
|
|
281
|
-
print(extra_msg)
|
|
282
|
-
sys.exit(1)
|
|
283
|
-
|
|
284
|
-
def load_positions(pos_file=None):
|
|
285
|
-
"""Load all positions - redirects to load_all_positions()"""
|
|
286
|
-
# pos_file parameter kept for compatibility but ignored
|
|
287
|
-
return load_all_positions()
|
|
457
|
+
return None
|
|
288
458
|
|
|
289
459
|
def send_message(from_instance, message):
|
|
290
460
|
"""Send a message to the log"""
|
|
291
461
|
try:
|
|
292
|
-
|
|
293
|
-
log_file = get_hcom_dir() / "hcom.log"
|
|
294
|
-
pos_file = get_hcom_dir() / "hcom.json"
|
|
462
|
+
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
295
463
|
|
|
296
464
|
escaped_message = message.replace('|', '\\|')
|
|
297
465
|
escaped_from = from_instance.replace('|', '\\|')
|
|
@@ -299,7 +467,7 @@ def send_message(from_instance, message):
|
|
|
299
467
|
timestamp = datetime.now().isoformat()
|
|
300
468
|
line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
|
|
301
469
|
|
|
302
|
-
with open(log_file, 'a') as f:
|
|
470
|
+
with open(log_file, 'a', encoding='utf-8') as f:
|
|
303
471
|
f.write(line)
|
|
304
472
|
f.flush()
|
|
305
473
|
|
|
@@ -367,7 +535,7 @@ def parse_open_args(args):
|
|
|
367
535
|
raise ValueError(format_error('--prefix requires an argument'))
|
|
368
536
|
prefix = args[i + 1]
|
|
369
537
|
if '|' in prefix:
|
|
370
|
-
raise ValueError(format_error('
|
|
538
|
+
raise ValueError(format_error('Prefix cannot contain pipe characters'))
|
|
371
539
|
i += 2
|
|
372
540
|
elif arg == '--claude-args':
|
|
373
541
|
# Next argument contains claude args as a string
|
|
@@ -436,44 +604,75 @@ def resolve_agent(name):
|
|
|
436
604
|
|
|
437
605
|
Returns tuple: (content after stripping YAML frontmatter, config dict)
|
|
438
606
|
"""
|
|
439
|
-
for base_path in [Path(
|
|
607
|
+
for base_path in [Path.cwd(), Path.home()]:
|
|
440
608
|
agent_path = base_path / '.claude/agents' / f'{name}.md'
|
|
441
609
|
if agent_path.exists():
|
|
442
|
-
content =
|
|
610
|
+
content = read_file_with_retry(
|
|
611
|
+
agent_path,
|
|
612
|
+
lambda f: f.read(),
|
|
613
|
+
default=None
|
|
614
|
+
)
|
|
615
|
+
if content is None:
|
|
616
|
+
continue # Skip to next base_path if read failed
|
|
443
617
|
config = extract_agent_config(content)
|
|
444
618
|
stripped = strip_frontmatter(content)
|
|
445
619
|
if not stripped.strip():
|
|
446
|
-
raise ValueError(format_error(f"Agent '{name}' has empty content", 'Check the agent file
|
|
620
|
+
raise ValueError(format_error(f"Agent '{name}' has empty content", 'Check the agent file is a valid format and contains text'))
|
|
447
621
|
return stripped, config
|
|
448
622
|
|
|
449
|
-
raise FileNotFoundError(format_error(f'Agent not found
|
|
623
|
+
raise FileNotFoundError(format_error(f'Agent "{name}" not found in project or user .claude/agents/ folder', 'Check available agents or create the agent file'))
|
|
450
624
|
|
|
451
625
|
def strip_frontmatter(content):
|
|
452
626
|
"""Strip YAML frontmatter from agent file"""
|
|
453
627
|
if content.startswith('---'):
|
|
454
628
|
# Find the closing --- on its own line
|
|
455
|
-
lines = content.
|
|
629
|
+
lines = content.splitlines()
|
|
456
630
|
for i, line in enumerate(lines[1:], 1):
|
|
457
631
|
if line.strip() == '---':
|
|
458
632
|
return '\n'.join(lines[i+1:]).strip()
|
|
459
633
|
return content
|
|
460
634
|
|
|
461
|
-
def get_display_name(
|
|
462
|
-
"""Get display name for instance"""
|
|
635
|
+
def get_display_name(session_id, prefix=None):
|
|
636
|
+
"""Get display name for instance using session_id"""
|
|
463
637
|
syls = ['ka', 'ko', 'ma', 'mo', 'na', 'no', 'ra', 'ro', 'sa', 'so', 'ta', 'to', 'va', 'vo', 'za', 'zo', 'be', 'de', 'fe', 'ge', 'le', 'me', 'ne', 're', 'se', 'te', 've', 'we', 'hi']
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
638
|
+
# Phonetic letters (5 per syllable, matches syls order)
|
|
639
|
+
phonetic = "nrlstnrlstnrlstnrlstnrlstnrlstnmlstnmlstnrlmtnrlmtnrlmsnrlmsnrlstnrlstnrlmtnrlmtnrlaynrlaynrlaynrlayaanxrtanxrtdtraxntdaxntraxnrdaynrlaynrlasnrlst"
|
|
640
|
+
|
|
641
|
+
dir_char = (Path.cwd().name + 'x')[0].lower()
|
|
642
|
+
|
|
643
|
+
# Use session_id directly instead of extracting UUID from transcript
|
|
644
|
+
if session_id:
|
|
645
|
+
hash_val = sum(ord(c) for c in session_id)
|
|
646
|
+
syl_idx = hash_val % len(syls)
|
|
647
|
+
syllable = syls[syl_idx]
|
|
648
|
+
|
|
649
|
+
letters = phonetic[syl_idx * 5:(syl_idx + 1) * 5]
|
|
650
|
+
letter_hash = sum(ord(c) for c in session_id[1:]) if len(session_id) > 1 else hash_val
|
|
651
|
+
letter = letters[letter_hash % 5]
|
|
652
|
+
|
|
653
|
+
# Session IDs are UUIDs like "374acbe2-978b-4882-9c0b-641890f066e1"
|
|
654
|
+
hex_char = session_id[0] if session_id else 'x'
|
|
655
|
+
base_name = f"{dir_char}{syllable}{letter}{hex_char}"
|
|
656
|
+
|
|
657
|
+
# Collision detection: if taken by another PID, use more session_id chars
|
|
658
|
+
instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
|
|
659
|
+
if instance_file.exists():
|
|
660
|
+
try:
|
|
661
|
+
with open(instance_file, 'r', encoding='utf-8') as f:
|
|
662
|
+
data = json.load(f)
|
|
663
|
+
their_pid = data.get('pid')
|
|
664
|
+
our_pid = os.getppid()
|
|
665
|
+
# Only consider it a collision if they have a PID and it's different
|
|
666
|
+
if their_pid and their_pid != our_pid:
|
|
667
|
+
# Use first 4 chars of session_id for collision resolution
|
|
668
|
+
base_name = f"{dir_char}{session_id[0:4]}"
|
|
669
|
+
except:
|
|
670
|
+
pass
|
|
473
671
|
else:
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
672
|
+
# Fallback to PID-based naming if no session_id
|
|
673
|
+
pid_suffix = os.getppid() % 10000
|
|
674
|
+
base_name = f"{dir_char}{pid_suffix}claude"
|
|
675
|
+
|
|
477
676
|
if prefix:
|
|
478
677
|
return f"{prefix}-{base_name}"
|
|
479
678
|
return base_name
|
|
@@ -498,18 +697,22 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
498
697
|
# - sh -c "[ ... ] && ... hcom ..."
|
|
499
698
|
# - "/path with spaces/python" "/path with spaces/hcom.py" post/stop/notify
|
|
500
699
|
# - '/path/to/python' '/path/to/hcom.py' post/stop/notify
|
|
700
|
+
# Note: Modern hooks use either ${HCOM:-true} (pattern 1) or the HCOM_ACTIVE conditional
|
|
701
|
+
# with full paths (pattern 2), both of which match all hook types including pre/sessionstart.
|
|
702
|
+
# The (post|stop|notify) patterns (3-6) are for older direct command formats that didn't
|
|
703
|
+
# include pre/sessionstart hooks.
|
|
501
704
|
hcom_patterns = [
|
|
502
|
-
r'\$\{?HCOM', # Environment variable (
|
|
503
|
-
r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with
|
|
504
|
-
r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command
|
|
505
|
-
r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command
|
|
506
|
-
r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote
|
|
507
|
-
r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b(?=\s|$)', # Quoted path
|
|
705
|
+
r'\$\{?HCOM', # Environment variable (${HCOM:-true}) - all hook types
|
|
706
|
+
r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with full path - all hook types
|
|
707
|
+
r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command (older format)
|
|
708
|
+
r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command (older format)
|
|
709
|
+
r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote (older format)
|
|
710
|
+
r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b(?=\s|$)', # Quoted path (older format)
|
|
508
711
|
r'sh\s+-c.*hcom', # Shell wrapper with hcom
|
|
509
712
|
]
|
|
510
713
|
compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
|
|
511
714
|
|
|
512
|
-
for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
|
|
715
|
+
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
|
|
513
716
|
if event not in settings['hooks']:
|
|
514
717
|
continue
|
|
515
718
|
|
|
@@ -547,20 +750,14 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
547
750
|
|
|
548
751
|
|
|
549
752
|
def build_env_string(env_vars, format_type="bash"):
|
|
550
|
-
"""Build environment variable string for
|
|
753
|
+
"""Build environment variable string for bash shells"""
|
|
551
754
|
if format_type == "bash_export":
|
|
552
755
|
# Properly escape values for bash
|
|
553
756
|
return ' '.join(f'export {k}={shlex.quote(str(v))};' for k, v in env_vars.items())
|
|
554
|
-
elif format_type == "powershell":
|
|
555
|
-
# PowerShell environment variable syntax
|
|
556
|
-
items = []
|
|
557
|
-
for k, v in env_vars.items():
|
|
558
|
-
escaped_value = str(v).replace('"', '`"')
|
|
559
|
-
items.append(f'$env:{k}="{escaped_value}"')
|
|
560
|
-
return ' ; '.join(items)
|
|
561
757
|
else:
|
|
562
758
|
return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
|
|
563
759
|
|
|
760
|
+
|
|
564
761
|
def format_error(message, suggestion=None):
|
|
565
762
|
"""Format error message consistently"""
|
|
566
763
|
base = f"Error: {message}"
|
|
@@ -568,38 +765,31 @@ def format_error(message, suggestion=None):
|
|
|
568
765
|
base += f". {suggestion}"
|
|
569
766
|
return base
|
|
570
767
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
768
|
+
|
|
769
|
+
def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
770
|
+
"""Check if argument already exists in claude_args"""
|
|
771
|
+
return claude_args and any(
|
|
772
|
+
arg in arg_names or arg.startswith(arg_prefixes)
|
|
773
|
+
for arg in claude_args
|
|
774
|
+
)
|
|
574
775
|
|
|
575
776
|
def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat", model=None, tools=None):
|
|
576
777
|
"""Build Claude command with proper argument handling
|
|
577
|
-
|
|
578
778
|
Returns tuple: (command_string, temp_file_path_or_none)
|
|
579
779
|
For agent content, writes to temp file and uses cat to read it.
|
|
580
780
|
"""
|
|
581
781
|
cmd_parts = ['claude']
|
|
582
782
|
temp_file_path = None
|
|
583
|
-
|
|
783
|
+
|
|
584
784
|
# Add model if specified and not already in claude_args
|
|
585
785
|
if model:
|
|
586
|
-
|
|
587
|
-
has_model = claude_args and any(
|
|
588
|
-
arg in ['--model', '-m'] or
|
|
589
|
-
arg.startswith(('--model=', '-m='))
|
|
590
|
-
for arg in claude_args
|
|
591
|
-
)
|
|
592
|
-
if not has_model:
|
|
786
|
+
if not has_claude_arg(claude_args, ['--model', '-m'], ('--model=', '-m=')):
|
|
593
787
|
cmd_parts.extend(['--model', model])
|
|
594
|
-
|
|
788
|
+
|
|
595
789
|
# Add allowed tools if specified and not already in claude_args
|
|
596
790
|
if tools:
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
arg.startswith(('--allowedTools=', '--allowed-tools='))
|
|
600
|
-
for arg in claude_args
|
|
601
|
-
)
|
|
602
|
-
if not has_tools:
|
|
791
|
+
if not has_claude_arg(claude_args, ['--allowedTools', '--allowed-tools'],
|
|
792
|
+
('--allowedTools=', '--allowed-tools=')):
|
|
603
793
|
cmd_parts.extend(['--allowedTools', tools])
|
|
604
794
|
|
|
605
795
|
if claude_args:
|
|
@@ -607,8 +797,11 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
607
797
|
cmd_parts.append(shlex.quote(arg))
|
|
608
798
|
|
|
609
799
|
if agent_content:
|
|
610
|
-
|
|
611
|
-
|
|
800
|
+
# Create agent files in scripts directory for unified cleanup
|
|
801
|
+
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
802
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
803
|
+
temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
|
|
804
|
+
prefix='hcom_agent_', dir=str(scripts_dir))
|
|
612
805
|
temp_file.write(agent_content)
|
|
613
806
|
temp_file.close()
|
|
614
807
|
temp_file_path = temp_file.name
|
|
@@ -619,12 +812,7 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
619
812
|
flag = '--append-system-prompt'
|
|
620
813
|
|
|
621
814
|
cmd_parts.append(flag)
|
|
622
|
-
|
|
623
|
-
# PowerShell handles paths differently, quote with single quotes
|
|
624
|
-
escaped_path = temp_file_path.replace("'", "''")
|
|
625
|
-
cmd_parts.append(f"\"$(Get-Content '{escaped_path}' -Raw)\"")
|
|
626
|
-
else:
|
|
627
|
-
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
815
|
+
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
628
816
|
|
|
629
817
|
if claude_args or agent_content:
|
|
630
818
|
cmd_parts.append('--')
|
|
@@ -634,46 +822,224 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
634
822
|
|
|
635
823
|
return ' '.join(cmd_parts), temp_file_path
|
|
636
824
|
|
|
637
|
-
def
|
|
638
|
-
"""
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
825
|
+
def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
826
|
+
"""Create a bash script for terminal launch
|
|
827
|
+
Scripts provide uniform execution across all platforms/terminals.
|
|
828
|
+
Cleanup behavior:
|
|
829
|
+
- Normal scripts: append 'rm -f' command for self-deletion
|
|
830
|
+
- Background scripts: persist until `hcom clear` housekeeping (24 hours)
|
|
831
|
+
- Agent scripts: treated like background (contain 'hcom_agent_')
|
|
832
|
+
"""
|
|
833
|
+
try:
|
|
834
|
+
# Ensure parent directory exists
|
|
835
|
+
script_path = Path(script_file)
|
|
836
|
+
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
837
|
+
except (OSError, IOError) as e:
|
|
838
|
+
raise Exception(f"Cannot create script directory: {e}")
|
|
839
|
+
|
|
840
|
+
with open(script_file, 'w', encoding='utf-8') as f:
|
|
841
|
+
f.write('#!/bin/bash\n')
|
|
842
|
+
f.write('echo "Starting Claude Code..."\n')
|
|
843
|
+
|
|
844
|
+
if platform.system() != 'Windows':
|
|
845
|
+
# 1. Discover paths once
|
|
846
|
+
claude_path = shutil.which('claude')
|
|
847
|
+
node_path = shutil.which('node')
|
|
848
|
+
|
|
849
|
+
# 2. Add to PATH for minimal environments
|
|
850
|
+
paths_to_add = []
|
|
851
|
+
for p in [node_path, claude_path]:
|
|
852
|
+
if p:
|
|
853
|
+
dir_path = os.path.dirname(os.path.realpath(p))
|
|
854
|
+
if dir_path not in paths_to_add:
|
|
855
|
+
paths_to_add.append(dir_path)
|
|
856
|
+
|
|
857
|
+
if paths_to_add:
|
|
858
|
+
path_addition = ':'.join(paths_to_add)
|
|
859
|
+
f.write(f'export PATH="{path_addition}:$PATH"\n')
|
|
860
|
+
elif not claude_path:
|
|
861
|
+
# Warning for debugging
|
|
862
|
+
print("Warning: Could not locate 'claude' in PATH", file=sys.stderr)
|
|
863
|
+
|
|
864
|
+
# 3. Write environment variables
|
|
865
|
+
f.write(build_env_string(env, "bash_export") + '\n')
|
|
866
|
+
|
|
867
|
+
if cwd:
|
|
868
|
+
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
869
|
+
|
|
870
|
+
# 4. Platform-specific command modifications
|
|
871
|
+
if claude_path:
|
|
872
|
+
if is_termux():
|
|
873
|
+
# Termux: explicit node to bypass shebang issues
|
|
874
|
+
final_node = node_path or '/data/data/com.termux/files/usr/bin/node'
|
|
875
|
+
# Quote paths for safety
|
|
876
|
+
command_str = command_str.replace(
|
|
877
|
+
'claude ',
|
|
878
|
+
f'{shlex.quote(final_node)} {shlex.quote(claude_path)} ',
|
|
879
|
+
1
|
|
880
|
+
)
|
|
881
|
+
else:
|
|
882
|
+
# Mac/Linux: use full path (PATH now has node if needed)
|
|
883
|
+
command_str = command_str.replace('claude ', f'{shlex.quote(claude_path)} ', 1)
|
|
884
|
+
else:
|
|
885
|
+
# Windows: no PATH modification needed
|
|
886
|
+
f.write(build_env_string(env, "bash_export") + '\n')
|
|
887
|
+
if cwd:
|
|
888
|
+
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
889
|
+
|
|
890
|
+
f.write(f'{command_str}\n')
|
|
891
|
+
|
|
892
|
+
# Self-delete for normal mode (not background or agent)
|
|
893
|
+
if not background and 'hcom_agent_' not in command_str:
|
|
894
|
+
f.write(f'rm -f {shlex.quote(script_file)}\n')
|
|
895
|
+
|
|
896
|
+
# Make executable on Unix
|
|
897
|
+
if platform.system() != 'Windows':
|
|
898
|
+
os.chmod(script_file, 0o755)
|
|
899
|
+
|
|
900
|
+
def find_bash_on_windows():
|
|
901
|
+
"""Find Git Bash on Windows, avoiding WSL's bash launcher"""
|
|
902
|
+
# Build prioritized list of bash candidates
|
|
903
|
+
candidates = []
|
|
904
|
+
|
|
905
|
+
# 1. Common Git Bash locations (highest priority)
|
|
906
|
+
for base in [os.environ.get('PROGRAMFILES', r'C:\Program Files'),
|
|
907
|
+
os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
|
|
908
|
+
if base:
|
|
909
|
+
candidates.extend([
|
|
910
|
+
os.path.join(base, 'Git', 'usr', 'bin', 'bash.exe'), # usr/bin is more common
|
|
911
|
+
os.path.join(base, 'Git', 'bin', 'bash.exe')
|
|
912
|
+
])
|
|
913
|
+
|
|
914
|
+
# 2. Portable Git installation
|
|
915
|
+
local_appdata = os.environ.get('LOCALAPPDATA', '')
|
|
916
|
+
if local_appdata:
|
|
917
|
+
git_portable = os.path.join(local_appdata, 'Programs', 'Git')
|
|
918
|
+
candidates.extend([
|
|
919
|
+
os.path.join(git_portable, 'usr', 'bin', 'bash.exe'),
|
|
920
|
+
os.path.join(git_portable, 'bin', 'bash.exe')
|
|
921
|
+
])
|
|
922
|
+
|
|
923
|
+
# 3. PATH bash (if not WSL's launcher)
|
|
924
|
+
path_bash = shutil.which('bash')
|
|
925
|
+
if path_bash and not path_bash.lower().endswith(r'system32\bash.exe'):
|
|
926
|
+
candidates.append(path_bash)
|
|
927
|
+
|
|
928
|
+
# 4. Hardcoded fallbacks (last resort)
|
|
929
|
+
candidates.extend([
|
|
930
|
+
r'C:\Program Files\Git\usr\bin\bash.exe',
|
|
931
|
+
r'C:\Program Files\Git\bin\bash.exe',
|
|
932
|
+
r'C:\Program Files (x86)\Git\usr\bin\bash.exe',
|
|
933
|
+
r'C:\Program Files (x86)\Git\bin\bash.exe'
|
|
934
|
+
])
|
|
935
|
+
|
|
936
|
+
# Find first existing bash
|
|
937
|
+
for bash in candidates:
|
|
938
|
+
if bash and os.path.exists(bash):
|
|
939
|
+
return bash
|
|
940
|
+
|
|
941
|
+
return None
|
|
942
|
+
|
|
943
|
+
# New helper functions for platform-specific terminal launching
|
|
944
|
+
def get_macos_terminal_argv():
|
|
945
|
+
"""Return macOS Terminal.app launch command as argv list."""
|
|
946
|
+
return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
|
|
947
|
+
|
|
948
|
+
def get_windows_terminal_argv():
|
|
949
|
+
"""Return Windows terminal launcher as argv list."""
|
|
950
|
+
bash_exe = find_bash_on_windows()
|
|
951
|
+
if not bash_exe:
|
|
952
|
+
raise Exception(format_error("Git Bash not found"))
|
|
953
|
+
|
|
954
|
+
if shutil.which('wt'):
|
|
955
|
+
return ['wt', bash_exe, '{script}']
|
|
956
|
+
return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
|
|
957
|
+
|
|
958
|
+
def get_linux_terminal_argv():
|
|
959
|
+
"""Return first available Linux terminal as argv list."""
|
|
960
|
+
terminals = [
|
|
961
|
+
('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
|
|
962
|
+
('konsole', ['konsole', '-e', 'bash', '{script}']),
|
|
963
|
+
('xterm', ['xterm', '-e', 'bash', '{script}']),
|
|
964
|
+
]
|
|
965
|
+
for term_name, argv_template in terminals:
|
|
966
|
+
if shutil.which(term_name):
|
|
967
|
+
return argv_template
|
|
968
|
+
|
|
969
|
+
# WSL fallback integrated here
|
|
970
|
+
if is_wsl() and shutil.which('cmd.exe'):
|
|
971
|
+
if shutil.which('wt.exe'):
|
|
972
|
+
return ['cmd.exe', '/c', 'start', 'wt.exe', 'bash', '{script}']
|
|
973
|
+
return ['cmd.exe', '/c', 'start', 'bash', '{script}']
|
|
974
|
+
|
|
975
|
+
return None
|
|
976
|
+
|
|
977
|
+
def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
|
|
978
|
+
"""Create hidden Windows process without console window."""
|
|
979
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
980
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
981
|
+
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
982
|
+
|
|
983
|
+
return subprocess.Popen(
|
|
984
|
+
argv,
|
|
985
|
+
env=env,
|
|
986
|
+
cwd=cwd,
|
|
987
|
+
stdin=subprocess.DEVNULL,
|
|
988
|
+
stdout=stdout,
|
|
989
|
+
stderr=subprocess.STDOUT,
|
|
990
|
+
startupinfo=startupinfo,
|
|
991
|
+
creationflags=CREATE_NO_WINDOW
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# Platform dispatch map
|
|
995
|
+
PLATFORM_TERMINAL_GETTERS = {
|
|
996
|
+
'Darwin': get_macos_terminal_argv,
|
|
997
|
+
'Windows': get_windows_terminal_argv,
|
|
998
|
+
'Linux': get_linux_terminal_argv,
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
def _parse_terminal_command(template, script_file):
|
|
1002
|
+
"""Parse terminal command template safely to prevent shell injection.
|
|
1003
|
+
Parses the template FIRST, then replaces {script} placeholder in the
|
|
1004
|
+
parsed tokens. This avoids shell injection and handles paths with spaces.
|
|
1005
|
+
Args:
|
|
1006
|
+
template: Terminal command template with {script} placeholder
|
|
1007
|
+
script_file: Path to script file to substitute
|
|
1008
|
+
Returns:
|
|
1009
|
+
list: Parsed command as argv array
|
|
1010
|
+
Raises:
|
|
1011
|
+
ValueError: If template is invalid or missing {script} placeholder
|
|
1012
|
+
"""
|
|
1013
|
+
if '{script}' not in template:
|
|
1014
|
+
raise ValueError(format_error("Custom terminal command must include {script} placeholder",
|
|
1015
|
+
'Example: open -n -a kitty.app --args bash "{script}"'))
|
|
1016
|
+
|
|
1017
|
+
try:
|
|
1018
|
+
parts = shlex.split(template)
|
|
1019
|
+
except ValueError as e:
|
|
1020
|
+
raise ValueError(format_error(f"Invalid terminal command syntax: {e}",
|
|
1021
|
+
"Check for unmatched quotes or invalid shell syntax"))
|
|
1022
|
+
|
|
1023
|
+
# Replace {script} in parsed tokens
|
|
1024
|
+
replaced = []
|
|
1025
|
+
placeholder_found = False
|
|
1026
|
+
for part in parts:
|
|
1027
|
+
if '{script}' in part:
|
|
1028
|
+
replaced.append(part.replace('{script}', script_file))
|
|
1029
|
+
placeholder_found = True
|
|
1030
|
+
else:
|
|
1031
|
+
replaced.append(part)
|
|
1032
|
+
|
|
1033
|
+
if not placeholder_found:
|
|
1034
|
+
raise ValueError(format_error("{script} placeholder not found after parsing",
|
|
1035
|
+
"Ensure {script} is not inside environment variables"))
|
|
1036
|
+
|
|
1037
|
+
return replaced
|
|
671
1038
|
|
|
672
1039
|
def launch_terminal(command, env, config=None, cwd=None, background=False):
|
|
673
|
-
"""Launch terminal with command
|
|
674
|
-
|
|
1040
|
+
"""Launch terminal with command using unified script-first approach
|
|
675
1041
|
Args:
|
|
676
|
-
command:
|
|
1042
|
+
command: Command string from build_claude_command
|
|
677
1043
|
env: Environment variables to set
|
|
678
1044
|
config: Configuration dict
|
|
679
1045
|
cwd: Working directory
|
|
@@ -681,136 +1047,159 @@ def launch_terminal(command, env, config=None, cwd=None, background=False):
|
|
|
681
1047
|
"""
|
|
682
1048
|
if config is None:
|
|
683
1049
|
config = get_cached_config()
|
|
684
|
-
|
|
1050
|
+
|
|
685
1051
|
env_vars = os.environ.copy()
|
|
686
1052
|
env_vars.update(env)
|
|
687
|
-
|
|
688
|
-
# Command should now always be a string from build_claude_command
|
|
689
1053
|
command_str = command
|
|
690
|
-
|
|
691
|
-
#
|
|
1054
|
+
|
|
1055
|
+
# 1) Always create a script
|
|
1056
|
+
script_file = str(hcom_path(SCRIPTS_DIR,
|
|
1057
|
+
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh',
|
|
1058
|
+
ensure_parent=True))
|
|
1059
|
+
create_bash_script(script_file, env, cwd, command_str, background)
|
|
1060
|
+
|
|
1061
|
+
# 2) Background mode
|
|
692
1062
|
if background:
|
|
693
|
-
|
|
694
|
-
logs_dir =
|
|
695
|
-
logs_dir.mkdir(exist_ok=True)
|
|
1063
|
+
logs_dir = hcom_path(LOGS_DIR)
|
|
1064
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
696
1065
|
log_file = logs_dir / env['HCOM_BACKGROUND']
|
|
697
|
-
|
|
698
|
-
# Launch detached process
|
|
1066
|
+
|
|
699
1067
|
try:
|
|
700
|
-
with open(log_file, 'w') as log_handle:
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1068
|
+
with open(log_file, 'w', encoding='utf-8') as log_handle:
|
|
1069
|
+
if IS_WINDOWS:
|
|
1070
|
+
# Windows: hidden bash execution with Python-piped logs
|
|
1071
|
+
bash_exe = find_bash_on_windows()
|
|
1072
|
+
if not bash_exe:
|
|
1073
|
+
raise Exception("Git Bash not found")
|
|
1074
|
+
|
|
1075
|
+
process = windows_hidden_popen(
|
|
1076
|
+
[bash_exe, script_file],
|
|
1077
|
+
env=env_vars,
|
|
1078
|
+
cwd=cwd,
|
|
1079
|
+
stdout=log_handle
|
|
1080
|
+
)
|
|
1081
|
+
else:
|
|
1082
|
+
# Unix(Mac/Linux/Termux): detached bash execution with Python-piped logs
|
|
1083
|
+
process = subprocess.Popen(
|
|
1084
|
+
['bash', script_file],
|
|
1085
|
+
env=env_vars, cwd=cwd,
|
|
1086
|
+
stdin=subprocess.DEVNULL,
|
|
1087
|
+
stdout=log_handle, stderr=subprocess.STDOUT,
|
|
1088
|
+
start_new_session=True
|
|
1089
|
+
)
|
|
1090
|
+
|
|
711
1091
|
except OSError as e:
|
|
712
|
-
print(f"
|
|
1092
|
+
print(format_error(f"Failed to launch background instance: {e}"), file=sys.stderr)
|
|
713
1093
|
return None
|
|
714
|
-
|
|
715
|
-
#
|
|
1094
|
+
|
|
1095
|
+
# Health check
|
|
716
1096
|
time.sleep(0.2)
|
|
717
1097
|
if process.poll() is not None:
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
error_output = f.read()[:1000]
|
|
721
|
-
print(f"Error: Background instance failed immediately", file=sys.stderr)
|
|
1098
|
+
error_output = read_file_with_retry(log_file, lambda f: f.read()[:1000], default="")
|
|
1099
|
+
print(format_error("Background instance failed immediately"), file=sys.stderr)
|
|
722
1100
|
if error_output:
|
|
723
1101
|
print(f" Output: {error_output}", file=sys.stderr)
|
|
724
1102
|
return None
|
|
725
|
-
|
|
1103
|
+
|
|
726
1104
|
return str(log_file)
|
|
727
|
-
|
|
1105
|
+
|
|
1106
|
+
# 3) Terminal modes
|
|
728
1107
|
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
729
|
-
|
|
1108
|
+
|
|
730
1109
|
if terminal_mode == 'show_commands':
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1110
|
+
# Print script path and contents
|
|
1111
|
+
try:
|
|
1112
|
+
with open(script_file, 'r', encoding='utf-8') as f:
|
|
1113
|
+
script_content = f.read()
|
|
1114
|
+
print(f"# Script: {script_file}")
|
|
1115
|
+
print(script_content)
|
|
1116
|
+
os.unlink(script_file) # Clean up immediately
|
|
1117
|
+
return True
|
|
1118
|
+
except Exception as e:
|
|
1119
|
+
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
1120
|
+
return False
|
|
1121
|
+
|
|
1122
|
+
if terminal_mode == 'same_terminal':
|
|
1123
|
+
print("Launching Claude in current terminal...")
|
|
1124
|
+
if IS_WINDOWS:
|
|
1125
|
+
bash_exe = find_bash_on_windows()
|
|
1126
|
+
if not bash_exe:
|
|
1127
|
+
print(format_error("Git Bash not found"), file=sys.stderr)
|
|
1128
|
+
return False
|
|
1129
|
+
result = subprocess.run([bash_exe, script_file], env=env_vars, cwd=cwd)
|
|
1130
|
+
else:
|
|
1131
|
+
result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
|
|
738
1132
|
return result.returncode == 0
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1133
|
+
|
|
1134
|
+
# 4) New window mode
|
|
742
1135
|
custom_cmd = get_config_value('terminal_command')
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if result.returncode != 0:
|
|
757
|
-
raise subprocess.CalledProcessError(result.returncode, final_cmd, result.stderr)
|
|
758
|
-
return True
|
|
759
|
-
|
|
760
|
-
if system == 'Darwin': # macOS
|
|
761
|
-
env_setup = build_env_string(env, "bash_export")
|
|
762
|
-
# Include cd command if cwd is specified
|
|
763
|
-
if cwd:
|
|
764
|
-
full_cmd = f'cd {shlex.quote(cwd)}; {env_setup} {command_str}'
|
|
765
|
-
else:
|
|
766
|
-
full_cmd = f'{env_setup} {command_str}'
|
|
767
|
-
|
|
768
|
-
# Escape the command for AppleScript double-quoted string
|
|
769
|
-
escaped = escape_for_platform(full_cmd, 'applescript')
|
|
770
|
-
|
|
771
|
-
script = f'tell app "Terminal" to do script "{escaped}"'
|
|
772
|
-
subprocess.run(['osascript', '-e', script])
|
|
773
|
-
return True
|
|
774
|
-
|
|
775
|
-
elif system == 'Linux':
|
|
776
|
-
terminals = [
|
|
777
|
-
('gnome-terminal', ['gnome-terminal', '--', 'bash', '-c']),
|
|
778
|
-
('konsole', ['konsole', '-e', 'bash', '-c']),
|
|
779
|
-
('xterm', ['xterm', '-e', 'bash', '-c'])
|
|
780
|
-
]
|
|
781
|
-
|
|
782
|
-
for term_name, term_cmd in terminals:
|
|
783
|
-
if shutil.which(term_name):
|
|
784
|
-
env_cmd = build_env_string(env)
|
|
785
|
-
# Include cd command if cwd is specified
|
|
786
|
-
if cwd:
|
|
787
|
-
full_cmd = f'cd "{cwd}"; {env_cmd} {command_str}; exec bash'
|
|
788
|
-
else:
|
|
789
|
-
full_cmd = f'{env_cmd} {command_str}; exec bash'
|
|
790
|
-
subprocess.run(term_cmd + [full_cmd])
|
|
1136
|
+
|
|
1137
|
+
if not custom_cmd: # No string sentinel checks
|
|
1138
|
+
if is_termux():
|
|
1139
|
+
# Keep Termux as special case
|
|
1140
|
+
am_cmd = [
|
|
1141
|
+
'am', 'startservice', '--user', '0',
|
|
1142
|
+
'-n', 'com.termux/com.termux.app.RunCommandService',
|
|
1143
|
+
'-a', 'com.termux.RUN_COMMAND',
|
|
1144
|
+
'--es', 'com.termux.RUN_COMMAND_PATH', script_file,
|
|
1145
|
+
'--ez', 'com.termux.RUN_COMMAND_BACKGROUND', 'false'
|
|
1146
|
+
]
|
|
1147
|
+
try:
|
|
1148
|
+
subprocess.run(am_cmd, check=False)
|
|
791
1149
|
return True
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
#
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
if
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1150
|
+
except Exception as e:
|
|
1151
|
+
print(format_error(f"Failed to launch Termux: {e}"), file=sys.stderr)
|
|
1152
|
+
return False
|
|
1153
|
+
|
|
1154
|
+
# Unified platform handling via helpers
|
|
1155
|
+
system = platform.system()
|
|
1156
|
+
terminal_getter = PLATFORM_TERMINAL_GETTERS.get(system)
|
|
1157
|
+
if not terminal_getter:
|
|
1158
|
+
raise Exception(format_error(f"Unsupported platform: {system}"))
|
|
1159
|
+
|
|
1160
|
+
custom_cmd = terminal_getter()
|
|
1161
|
+
if not custom_cmd: # e.g., Linux with no terminals
|
|
1162
|
+
raise Exception(format_error("No supported terminal emulator found",
|
|
1163
|
+
"Install gnome-terminal, konsole, or xterm"))
|
|
1164
|
+
|
|
1165
|
+
# Type-based dispatch for execution
|
|
1166
|
+
if isinstance(custom_cmd, list):
|
|
1167
|
+
# Our argv commands - safe execution without shell
|
|
1168
|
+
final_argv = [arg.replace('{script}', script_file) for arg in custom_cmd]
|
|
804
1169
|
try:
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1170
|
+
if platform.system() == 'Windows':
|
|
1171
|
+
# Windows needs non-blocking for parallel launches
|
|
1172
|
+
subprocess.Popen(final_argv)
|
|
1173
|
+
return True # Popen is non-blocking, can't check success
|
|
1174
|
+
else:
|
|
1175
|
+
result = subprocess.run(final_argv)
|
|
1176
|
+
if result.returncode != 0:
|
|
1177
|
+
return False
|
|
1178
|
+
return True
|
|
1179
|
+
except Exception as e:
|
|
1180
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
1181
|
+
return False
|
|
812
1182
|
else:
|
|
813
|
-
|
|
1183
|
+
# User-provided string commands - parse safely without shell=True
|
|
1184
|
+
try:
|
|
1185
|
+
final_argv = _parse_terminal_command(custom_cmd, script_file)
|
|
1186
|
+
except ValueError as e:
|
|
1187
|
+
print(str(e), file=sys.stderr)
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
try:
|
|
1191
|
+
if platform.system() == 'Windows':
|
|
1192
|
+
# Windows needs non-blocking for parallel launches
|
|
1193
|
+
subprocess.Popen(final_argv)
|
|
1194
|
+
return True # Popen is non-blocking, can't check success
|
|
1195
|
+
else:
|
|
1196
|
+
result = subprocess.run(final_argv)
|
|
1197
|
+
if result.returncode != 0:
|
|
1198
|
+
return False
|
|
1199
|
+
return True
|
|
1200
|
+
except Exception as e:
|
|
1201
|
+
print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
|
|
1202
|
+
return False
|
|
814
1203
|
|
|
815
1204
|
def setup_hooks():
|
|
816
1205
|
"""Set up Claude hooks in current directory"""
|
|
@@ -818,80 +1207,50 @@ def setup_hooks():
|
|
|
818
1207
|
claude_dir.mkdir(exist_ok=True)
|
|
819
1208
|
|
|
820
1209
|
settings_path = claude_dir / 'settings.local.json'
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1210
|
+
try:
|
|
1211
|
+
settings = read_file_with_retry(
|
|
1212
|
+
settings_path,
|
|
1213
|
+
lambda f: json.load(f),
|
|
1214
|
+
default={}
|
|
1215
|
+
)
|
|
1216
|
+
except (json.JSONDecodeError, PermissionError) as e:
|
|
1217
|
+
raise Exception(format_error(f"Cannot read settings: {e}"))
|
|
829
1218
|
|
|
830
1219
|
if 'hooks' not in settings:
|
|
831
1220
|
settings['hooks'] = {}
|
|
832
|
-
|
|
833
|
-
settings['permissions'] = {}
|
|
834
|
-
if 'allow' not in settings['permissions']:
|
|
835
|
-
settings['permissions']['allow'] = []
|
|
836
|
-
|
|
1221
|
+
|
|
837
1222
|
_remove_hcom_hooks_from_settings(settings)
|
|
838
|
-
|
|
839
|
-
if 'hooks' not in settings:
|
|
840
|
-
settings['hooks'] = {}
|
|
841
1223
|
|
|
842
1224
|
# Get the hook command template
|
|
843
1225
|
hook_cmd_base, _ = get_hook_command()
|
|
844
1226
|
|
|
845
|
-
#
|
|
846
|
-
if 'PreToolUse' not in settings['hooks']:
|
|
847
|
-
settings['hooks']['PreToolUse'] = []
|
|
848
|
-
|
|
849
|
-
settings['hooks']['PreToolUse'].append({
|
|
850
|
-
'matcher': 'Bash',
|
|
851
|
-
'hooks': [{
|
|
852
|
-
'type': 'command',
|
|
853
|
-
'command': f'{hook_cmd_base} pre'
|
|
854
|
-
}]
|
|
855
|
-
})
|
|
856
|
-
|
|
857
|
-
# Add PostToolUse hook
|
|
858
|
-
if 'PostToolUse' not in settings['hooks']:
|
|
859
|
-
settings['hooks']['PostToolUse'] = []
|
|
860
|
-
|
|
861
|
-
settings['hooks']['PostToolUse'].append({
|
|
862
|
-
'matcher': '.*',
|
|
863
|
-
'hooks': [{
|
|
864
|
-
'type': 'command',
|
|
865
|
-
'command': f'{hook_cmd_base} post'
|
|
866
|
-
}]
|
|
867
|
-
})
|
|
868
|
-
|
|
869
|
-
# Add Stop hook
|
|
870
|
-
if 'Stop' not in settings['hooks']:
|
|
871
|
-
settings['hooks']['Stop'] = []
|
|
872
|
-
|
|
1227
|
+
# Get wait_timeout (needed for Stop hook)
|
|
873
1228
|
wait_timeout = get_config_value('wait_timeout', 1800)
|
|
874
1229
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
'
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1230
|
+
# Define all hooks
|
|
1231
|
+
hook_configs = [
|
|
1232
|
+
('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
|
|
1233
|
+
('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
|
|
1234
|
+
('PostToolUse', '.*', f'{hook_cmd_base} post', None),
|
|
1235
|
+
('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
|
|
1236
|
+
('Notification', '', f'{hook_cmd_base} notify', None),
|
|
1237
|
+
]
|
|
1238
|
+
|
|
1239
|
+
for hook_type, matcher, command, timeout in hook_configs:
|
|
1240
|
+
if hook_type not in settings['hooks']:
|
|
1241
|
+
settings['hooks'][hook_type] = []
|
|
1242
|
+
|
|
1243
|
+
hook_dict = {
|
|
1244
|
+
'matcher': matcher,
|
|
1245
|
+
'hooks': [{
|
|
1246
|
+
'type': 'command',
|
|
1247
|
+
'command': command
|
|
1248
|
+
}]
|
|
1249
|
+
}
|
|
1250
|
+
if timeout is not None:
|
|
1251
|
+
hook_dict['hooks'][0]['timeout'] = timeout
|
|
1252
|
+
|
|
1253
|
+
settings['hooks'][hook_type].append(hook_dict)
|
|
895
1254
|
|
|
896
1255
|
# Write settings atomically
|
|
897
1256
|
try:
|
|
@@ -908,16 +1267,21 @@ def setup_hooks():
|
|
|
908
1267
|
def verify_hooks_installed(settings_path):
|
|
909
1268
|
"""Verify that HCOM hooks were installed correctly"""
|
|
910
1269
|
try:
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1270
|
+
settings = read_file_with_retry(
|
|
1271
|
+
settings_path,
|
|
1272
|
+
lambda f: json.load(f),
|
|
1273
|
+
default=None
|
|
1274
|
+
)
|
|
1275
|
+
if not settings:
|
|
1276
|
+
return False
|
|
1277
|
+
|
|
1278
|
+
# Check all hook types exist with HCOM commands
|
|
915
1279
|
hooks = settings.get('hooks', {})
|
|
916
|
-
for hook_type in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
|
|
917
|
-
if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
|
|
1280
|
+
for hook_type in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
|
|
1281
|
+
if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
|
|
918
1282
|
for h in hooks.get(hook_type, [])):
|
|
919
1283
|
return False
|
|
920
|
-
|
|
1284
|
+
|
|
921
1285
|
return True
|
|
922
1286
|
except Exception:
|
|
923
1287
|
return False
|
|
@@ -930,86 +1294,101 @@ def get_archive_timestamp():
|
|
|
930
1294
|
"""Get timestamp for archive files"""
|
|
931
1295
|
return datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
932
1296
|
|
|
933
|
-
def get_conversation_uuid(transcript_path):
|
|
934
|
-
"""Get conversation UUID from transcript
|
|
935
|
-
|
|
936
|
-
For resumed sessions, the first line may be a summary with a different leafUuid.
|
|
937
|
-
We need to find the first user entry which contains the stable conversation UUID.
|
|
938
|
-
"""
|
|
939
|
-
try:
|
|
940
|
-
if not transcript_path or not os.path.exists(transcript_path):
|
|
941
|
-
return None
|
|
942
|
-
|
|
943
|
-
# First, try to find the UUID from the first user entry
|
|
944
|
-
with open(transcript_path, 'r') as f:
|
|
945
|
-
for line in f:
|
|
946
|
-
line = line.strip()
|
|
947
|
-
if not line:
|
|
948
|
-
continue
|
|
949
|
-
try:
|
|
950
|
-
entry = json.loads(line)
|
|
951
|
-
# Look for first user entry with a UUID - this is the stable identifier
|
|
952
|
-
if entry.get('type') == 'user' and entry.get('uuid'):
|
|
953
|
-
return entry.get('uuid')
|
|
954
|
-
except json.JSONDecodeError:
|
|
955
|
-
continue
|
|
956
|
-
|
|
957
|
-
# Fallback: If no user entry found, try the first line (original behavior)
|
|
958
|
-
with open(transcript_path, 'r') as f:
|
|
959
|
-
first_line = f.readline().strip()
|
|
960
|
-
if first_line:
|
|
961
|
-
entry = json.loads(first_line)
|
|
962
|
-
# Try both 'uuid' and 'leafUuid' fields
|
|
963
|
-
return entry.get('uuid') or entry.get('leafUuid')
|
|
964
|
-
except Exception:
|
|
965
|
-
pass
|
|
966
|
-
return None
|
|
967
|
-
|
|
968
1297
|
def is_parent_alive(parent_pid=None):
|
|
969
1298
|
"""Check if parent process is alive"""
|
|
970
1299
|
if parent_pid is None:
|
|
971
1300
|
parent_pid = os.getppid()
|
|
972
|
-
|
|
1301
|
+
|
|
1302
|
+
# Orphan detection - PID 1 == definitively orphaned
|
|
1303
|
+
if parent_pid == 1:
|
|
1304
|
+
return False
|
|
1305
|
+
|
|
1306
|
+
result = is_process_alive(parent_pid)
|
|
1307
|
+
return result
|
|
1308
|
+
|
|
1309
|
+
def is_process_alive(pid):
|
|
1310
|
+
"""Check if a process with given PID exists - cross-platform"""
|
|
1311
|
+
if pid is None:
|
|
1312
|
+
return False
|
|
1313
|
+
|
|
1314
|
+
try:
|
|
1315
|
+
pid = int(pid)
|
|
1316
|
+
except (TypeError, ValueError) as e:
|
|
1317
|
+
return False
|
|
1318
|
+
|
|
973
1319
|
if IS_WINDOWS:
|
|
1320
|
+
# Windows: Use Windows API to check process existence
|
|
974
1321
|
try:
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
handle = kernel32.OpenProcess(0x0400, False, parent_pid)
|
|
978
|
-
if handle == 0:
|
|
1322
|
+
kernel32 = get_windows_kernel32() # Use cached kernel32 instance
|
|
1323
|
+
if not kernel32:
|
|
979
1324
|
return False
|
|
1325
|
+
|
|
1326
|
+
# Try limited permissions first (more likely to succeed on Vista+)
|
|
1327
|
+
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
1328
|
+
error = kernel32.GetLastError()
|
|
1329
|
+
|
|
1330
|
+
if not handle: # Check for None or 0
|
|
1331
|
+
# ERROR_ACCESS_DENIED (5) means process exists but no permission
|
|
1332
|
+
if error == ERROR_ACCESS_DENIED:
|
|
1333
|
+
return True
|
|
1334
|
+
|
|
1335
|
+
# Try fallback with broader permissions for older Windows
|
|
1336
|
+
handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, False, pid)
|
|
1337
|
+
|
|
1338
|
+
if not handle: # Check for None or 0
|
|
1339
|
+
return False # Process doesn't exist or no permission at all
|
|
1340
|
+
|
|
1341
|
+
# Check if process is still running (not just if handle exists)
|
|
1342
|
+
import ctypes.wintypes
|
|
1343
|
+
exit_code = ctypes.wintypes.DWORD()
|
|
1344
|
+
STILL_ACTIVE = 259
|
|
1345
|
+
|
|
1346
|
+
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
1347
|
+
kernel32.CloseHandle(handle)
|
|
1348
|
+
is_still_active = exit_code.value == STILL_ACTIVE
|
|
1349
|
+
return is_still_active
|
|
1350
|
+
|
|
980
1351
|
kernel32.CloseHandle(handle)
|
|
981
|
-
return
|
|
982
|
-
except Exception:
|
|
983
|
-
return
|
|
1352
|
+
return False # Couldn't get exit code
|
|
1353
|
+
except Exception as e:
|
|
1354
|
+
return False
|
|
984
1355
|
else:
|
|
1356
|
+
# Unix: Use os.kill with signal 0
|
|
985
1357
|
try:
|
|
986
|
-
os.kill(
|
|
1358
|
+
os.kill(pid, 0)
|
|
987
1359
|
return True
|
|
988
|
-
except ProcessLookupError:
|
|
1360
|
+
except ProcessLookupError as e:
|
|
1361
|
+
return False
|
|
1362
|
+
except Exception as e:
|
|
989
1363
|
return False
|
|
990
|
-
except Exception:
|
|
991
|
-
return True
|
|
992
1364
|
|
|
993
|
-
def parse_log_messages(log_file, start_pos=0):
|
|
994
|
-
"""Parse messages from log file
|
|
995
|
-
|
|
1365
|
+
def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
|
|
1366
|
+
"""Parse messages from log file
|
|
1367
|
+
Args:
|
|
1368
|
+
log_file: Path to log file
|
|
1369
|
+
start_pos: Position to start reading from
|
|
1370
|
+
return_end_pos: If True, return tuple (messages, end_position)
|
|
1371
|
+
Returns:
|
|
1372
|
+
list of messages, or (messages, end_pos) if return_end_pos=True
|
|
1373
|
+
"""
|
|
996
1374
|
if not log_file.exists():
|
|
997
|
-
return []
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
with open(log_file, 'r') as f:
|
|
1375
|
+
return ([], start_pos) if return_end_pos else []
|
|
1376
|
+
|
|
1377
|
+
def read_messages(f):
|
|
1001
1378
|
f.seek(start_pos)
|
|
1002
1379
|
content = f.read()
|
|
1003
|
-
|
|
1380
|
+
end_pos = f.tell() # Capture actual end position
|
|
1381
|
+
|
|
1004
1382
|
if not content.strip():
|
|
1005
|
-
return []
|
|
1006
|
-
|
|
1383
|
+
return ([], end_pos)
|
|
1384
|
+
|
|
1385
|
+
messages = []
|
|
1007
1386
|
message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
|
|
1008
|
-
|
|
1387
|
+
|
|
1009
1388
|
for entry in message_entries:
|
|
1010
1389
|
if not entry or '|' not in entry:
|
|
1011
1390
|
continue
|
|
1012
|
-
|
|
1391
|
+
|
|
1013
1392
|
parts = entry.split('|', 2)
|
|
1014
1393
|
if len(parts) == 3:
|
|
1015
1394
|
timestamp, from_instance, message = parts
|
|
@@ -1018,27 +1397,35 @@ def parse_log_messages(log_file, start_pos=0):
|
|
|
1018
1397
|
'from': from_instance.replace('\\|', '|'),
|
|
1019
1398
|
'message': message.replace('\\|', '|')
|
|
1020
1399
|
})
|
|
1021
|
-
|
|
1022
|
-
|
|
1400
|
+
|
|
1401
|
+
return (messages, end_pos)
|
|
1402
|
+
|
|
1403
|
+
result = read_file_with_retry(
|
|
1404
|
+
log_file,
|
|
1405
|
+
read_messages,
|
|
1406
|
+
default=([], start_pos)
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
return result if return_end_pos else result[0]
|
|
1023
1410
|
|
|
1024
1411
|
def get_new_messages(instance_name):
|
|
1025
1412
|
"""Get new messages for instance with @-mention filtering"""
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1413
|
+
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
1414
|
+
|
|
1029
1415
|
if not log_file.exists():
|
|
1030
1416
|
return []
|
|
1031
|
-
|
|
1417
|
+
|
|
1032
1418
|
positions = load_all_positions()
|
|
1033
|
-
|
|
1419
|
+
|
|
1034
1420
|
# Get last position for this instance
|
|
1035
1421
|
last_pos = 0
|
|
1036
1422
|
if instance_name in positions:
|
|
1037
1423
|
pos_data = positions.get(instance_name, {})
|
|
1038
1424
|
last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1425
|
+
|
|
1426
|
+
# Atomic read with position tracking
|
|
1427
|
+
all_messages, new_pos = parse_log_messages(log_file, last_pos, return_end_pos=True)
|
|
1428
|
+
|
|
1042
1429
|
# Filter messages:
|
|
1043
1430
|
# 1. Exclude own messages
|
|
1044
1431
|
# 2. Apply @-mention filtering
|
|
@@ -1048,15 +1435,10 @@ def get_new_messages(instance_name):
|
|
|
1048
1435
|
if msg['from'] != instance_name:
|
|
1049
1436
|
if should_deliver_message(msg, instance_name, all_instance_names):
|
|
1050
1437
|
messages.append(msg)
|
|
1051
|
-
|
|
1052
|
-
# Update position to
|
|
1053
|
-
with open(log_file, 'r') as f:
|
|
1054
|
-
f.seek(0, 2) # Seek to end
|
|
1055
|
-
new_pos = f.tell()
|
|
1056
|
-
|
|
1057
|
-
# Update position directly
|
|
1438
|
+
|
|
1439
|
+
# Update position to what was actually processed
|
|
1058
1440
|
update_instance_position(instance_name, {'pos': new_pos})
|
|
1059
|
-
|
|
1441
|
+
|
|
1060
1442
|
return messages
|
|
1061
1443
|
|
|
1062
1444
|
def format_age(seconds):
|
|
@@ -1070,35 +1452,55 @@ def format_age(seconds):
|
|
|
1070
1452
|
|
|
1071
1453
|
def get_transcript_status(transcript_path):
|
|
1072
1454
|
"""Parse transcript to determine current Claude state"""
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1455
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
1456
|
+
return "inactive", "", "", 0
|
|
1457
|
+
|
|
1458
|
+
def read_status(f):
|
|
1459
|
+
# Windows file buffering fix: read entire file to get current content
|
|
1460
|
+
if IS_WINDOWS:
|
|
1461
|
+
# Seek to beginning and read all content to bypass Windows file caching
|
|
1462
|
+
f.seek(0)
|
|
1463
|
+
all_content = f.read()
|
|
1464
|
+
all_lines = all_content.strip().split('\n')
|
|
1465
|
+
lines = all_lines[-5:] if len(all_lines) >= 5 else all_lines
|
|
1466
|
+
else:
|
|
1078
1467
|
lines = f.readlines()[-5:]
|
|
1079
|
-
|
|
1080
|
-
for line in reversed(lines):
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
if '
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1468
|
+
|
|
1469
|
+
for i, line in enumerate(reversed(lines)):
|
|
1470
|
+
try:
|
|
1471
|
+
entry = json.loads(line)
|
|
1472
|
+
timestamp = datetime.fromisoformat(entry['timestamp']).timestamp()
|
|
1473
|
+
age = int(time.time() - timestamp)
|
|
1474
|
+
entry_type = entry.get('type', '')
|
|
1475
|
+
|
|
1476
|
+
if entry['type'] == 'system':
|
|
1477
|
+
content = entry.get('content', '')
|
|
1478
|
+
if 'Running' in content:
|
|
1479
|
+
tool_name = content.split('Running ')[1].split('[')[0].strip()
|
|
1480
|
+
return "executing", f"({format_age(age)})", tool_name, timestamp
|
|
1481
|
+
|
|
1482
|
+
elif entry['type'] == 'assistant':
|
|
1483
|
+
content = entry.get('content', [])
|
|
1484
|
+
has_tool_use = any('tool_use' in str(item) for item in content)
|
|
1485
|
+
if has_tool_use:
|
|
1486
|
+
return "executing", f"({format_age(age)})", "tool", timestamp
|
|
1487
|
+
else:
|
|
1488
|
+
return "responding", f"({format_age(age)})", "", timestamp
|
|
1489
|
+
|
|
1490
|
+
elif entry['type'] == 'user':
|
|
1491
|
+
return "thinking", f"({format_age(age)})", "", timestamp
|
|
1492
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
1493
|
+
continue
|
|
1494
|
+
|
|
1101
1495
|
return "inactive", "", "", 0
|
|
1496
|
+
|
|
1497
|
+
try:
|
|
1498
|
+
result = read_file_with_retry(
|
|
1499
|
+
transcript_path,
|
|
1500
|
+
read_status,
|
|
1501
|
+
default=("inactive", "", "", 0)
|
|
1502
|
+
)
|
|
1503
|
+
return result
|
|
1102
1504
|
except Exception:
|
|
1103
1505
|
return "inactive", "", "", 0
|
|
1104
1506
|
|
|
@@ -1106,7 +1508,7 @@ def get_instance_status(pos_data):
|
|
|
1106
1508
|
"""Get current status of instance"""
|
|
1107
1509
|
now = int(time.time())
|
|
1108
1510
|
wait_timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
|
|
1109
|
-
|
|
1511
|
+
|
|
1110
1512
|
# Check if process is still alive. pid: null means killed
|
|
1111
1513
|
# All real instances should have a PID (set by update_instance_with_pid)
|
|
1112
1514
|
if 'pid' in pos_data:
|
|
@@ -1114,48 +1516,49 @@ def get_instance_status(pos_data):
|
|
|
1114
1516
|
if pid is None:
|
|
1115
1517
|
# Explicitly null = was killed
|
|
1116
1518
|
return "inactive", ""
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1519
|
+
if not is_process_alive(pid):
|
|
1520
|
+
# On Windows, PID checks can fail during process transitions
|
|
1521
|
+
# Let timeout logic handle this using activity timestamps
|
|
1522
|
+
wait_timeout = 30 if IS_WINDOWS else wait_timeout # Shorter timeout when PID dead
|
|
1523
|
+
|
|
1122
1524
|
last_permission = pos_data.get("last_permission_request", 0)
|
|
1123
1525
|
last_stop = pos_data.get("last_stop", 0)
|
|
1124
1526
|
last_tool = pos_data.get("last_tool", 0)
|
|
1125
|
-
|
|
1527
|
+
|
|
1126
1528
|
transcript_timestamp = 0
|
|
1127
1529
|
transcript_status = "inactive"
|
|
1128
|
-
|
|
1530
|
+
|
|
1129
1531
|
transcript_path = pos_data.get("transcript_path", "")
|
|
1130
1532
|
if transcript_path:
|
|
1131
1533
|
status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
|
|
1132
1534
|
transcript_status = status
|
|
1133
|
-
|
|
1535
|
+
|
|
1134
1536
|
# Calculate last actual activity (excluding heartbeat)
|
|
1135
1537
|
last_activity = max(last_permission, last_tool, transcript_timestamp)
|
|
1136
|
-
|
|
1538
|
+
|
|
1137
1539
|
# Check timeout based on actual activity
|
|
1138
1540
|
if last_activity > 0 and (now - last_activity) > wait_timeout:
|
|
1139
1541
|
return "inactive", ""
|
|
1140
|
-
|
|
1542
|
+
|
|
1141
1543
|
# Now determine current status including heartbeat
|
|
1142
1544
|
events = [
|
|
1143
1545
|
(last_permission, "blocked"),
|
|
1144
|
-
(last_stop, "waiting"),
|
|
1546
|
+
(last_stop, "waiting"),
|
|
1145
1547
|
(last_tool, "inactive"),
|
|
1146
1548
|
(transcript_timestamp, transcript_status)
|
|
1147
1549
|
]
|
|
1148
|
-
|
|
1550
|
+
|
|
1149
1551
|
recent_events = [(ts, status) for ts, status in events if ts > 0]
|
|
1150
1552
|
if not recent_events:
|
|
1151
1553
|
return "inactive", ""
|
|
1152
|
-
|
|
1554
|
+
|
|
1153
1555
|
most_recent_time, most_recent_status = max(recent_events)
|
|
1154
1556
|
age = now - most_recent_time
|
|
1155
|
-
|
|
1156
|
-
# Add background indicator
|
|
1557
|
+
|
|
1157
1558
|
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1158
|
-
|
|
1559
|
+
final_result = (most_recent_status, f"({format_age(age)}){status_suffix}")
|
|
1560
|
+
|
|
1561
|
+
return final_result
|
|
1159
1562
|
|
|
1160
1563
|
def get_status_block(status_type):
|
|
1161
1564
|
"""Get colored status block for a status type"""
|
|
@@ -1206,28 +1609,18 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1206
1609
|
available_height = get_terminal_height() - 20
|
|
1207
1610
|
limit = max(2, available_height // 2)
|
|
1208
1611
|
|
|
1209
|
-
log_file =
|
|
1612
|
+
log_file = hcom_path(LOG_FILE)
|
|
1210
1613
|
if log_file.exists():
|
|
1211
1614
|
messages = parse_log_messages(log_file)
|
|
1212
1615
|
show_recent_messages(messages, limit, truncate=True)
|
|
1213
1616
|
|
|
1214
|
-
def
|
|
1215
|
-
"""Show
|
|
1617
|
+
def show_instances_by_directory():
|
|
1618
|
+
"""Show instances organized by their working directories"""
|
|
1216
1619
|
positions = load_all_positions()
|
|
1217
1620
|
if not positions:
|
|
1218
1621
|
print(f" {DIM}No Claude instances connected{RESET}")
|
|
1219
1622
|
return
|
|
1220
|
-
|
|
1221
|
-
print("Instances in hcom:")
|
|
1222
|
-
for instance_name, pos_data in positions.items():
|
|
1223
|
-
status_type, age = get_instance_status(pos_data)
|
|
1224
|
-
status_block = get_status_block(status_type)
|
|
1225
|
-
directory = pos_data.get("directory", "unknown")
|
|
1226
|
-
print(f" {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}{RESET} {directory}")
|
|
1227
|
-
|
|
1228
|
-
def show_instances_by_directory():
|
|
1229
|
-
"""Show instances organized by their working directories"""
|
|
1230
|
-
positions = load_all_positions()
|
|
1623
|
+
|
|
1231
1624
|
if positions:
|
|
1232
1625
|
directories = {}
|
|
1233
1626
|
for instance_name, pos_data in positions.items():
|
|
@@ -1245,10 +1638,7 @@ def show_instances_by_directory():
|
|
|
1245
1638
|
last_tool_name = pos_data.get("last_tool_name", "unknown")
|
|
1246
1639
|
last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
|
|
1247
1640
|
|
|
1248
|
-
|
|
1249
|
-
session_info = f" | {sid}" if sid else ""
|
|
1250
|
-
|
|
1251
|
-
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{session_info}{RESET}")
|
|
1641
|
+
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{RESET}")
|
|
1252
1642
|
print()
|
|
1253
1643
|
else:
|
|
1254
1644
|
print(f" {DIM}Error reading instance data{RESET}")
|
|
@@ -1259,12 +1649,8 @@ def alt_screen_detailed_status_and_input():
|
|
|
1259
1649
|
|
|
1260
1650
|
try:
|
|
1261
1651
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
1262
|
-
print(f"{BOLD}
|
|
1263
|
-
print(f"{
|
|
1264
|
-
print(f"{FG_CYAN} HCOM: GLOBAL CHAT{RESET}")
|
|
1265
|
-
print(f"{DIM} LOG FILE: {get_hcom_dir() / 'hcom.log'}{RESET}")
|
|
1266
|
-
print(f"{DIM} UPDATED: {timestamp}{RESET}")
|
|
1267
|
-
print(f"{BOLD}{'-' * 70}{RESET}")
|
|
1652
|
+
print(f"{BOLD}HCOM{RESET} STATUS {DIM}- UPDATED: {timestamp}{RESET}")
|
|
1653
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1268
1654
|
print()
|
|
1269
1655
|
|
|
1270
1656
|
show_instances_by_directory()
|
|
@@ -1275,11 +1661,11 @@ def alt_screen_detailed_status_and_input():
|
|
|
1275
1661
|
show_recent_activity_alt_screen()
|
|
1276
1662
|
|
|
1277
1663
|
print()
|
|
1278
|
-
print(f"{
|
|
1279
|
-
print(f"{FG_GREEN}
|
|
1664
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1665
|
+
print(f"{FG_GREEN} Press Enter to send message (empty to cancel):{RESET}")
|
|
1280
1666
|
message = input(f"{FG_CYAN} > {RESET}")
|
|
1281
|
-
|
|
1282
|
-
print(f"{
|
|
1667
|
+
|
|
1668
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1283
1669
|
|
|
1284
1670
|
finally:
|
|
1285
1671
|
sys.stdout.write("\033[?1049l")
|
|
@@ -1291,26 +1677,28 @@ def get_status_summary():
|
|
|
1291
1677
|
positions = load_all_positions()
|
|
1292
1678
|
if not positions:
|
|
1293
1679
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1294
|
-
|
|
1680
|
+
|
|
1295
1681
|
status_counts = {"thinking": 0, "responding": 0, "executing": 0, "waiting": 0, "blocked": 0, "inactive": 0}
|
|
1296
|
-
|
|
1297
|
-
for
|
|
1682
|
+
|
|
1683
|
+
for instance_name, pos_data in positions.items():
|
|
1298
1684
|
status_type, _ = get_instance_status(pos_data)
|
|
1299
1685
|
if status_type in status_counts:
|
|
1300
1686
|
status_counts[status_type] += 1
|
|
1301
|
-
|
|
1687
|
+
|
|
1302
1688
|
parts = []
|
|
1303
1689
|
status_order = ["thinking", "responding", "executing", "waiting", "blocked", "inactive"]
|
|
1304
|
-
|
|
1690
|
+
|
|
1305
1691
|
for status_type in status_order:
|
|
1306
1692
|
count = status_counts[status_type]
|
|
1307
1693
|
if count > 0:
|
|
1308
1694
|
color, symbol = STATUS_MAP[status_type]
|
|
1309
1695
|
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
1310
|
-
|
|
1311
|
-
|
|
1696
|
+
part = f"{text_color}{BOLD}{color} {count} {symbol} {RESET}"
|
|
1697
|
+
parts.append(part)
|
|
1698
|
+
|
|
1312
1699
|
if parts:
|
|
1313
|
-
|
|
1700
|
+
result = "".join(parts)
|
|
1701
|
+
return result
|
|
1314
1702
|
else:
|
|
1315
1703
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1316
1704
|
|
|
@@ -1325,79 +1713,130 @@ def log_line_with_status(message, status):
|
|
|
1325
1713
|
sys.stdout.write("\033[K" + status)
|
|
1326
1714
|
sys.stdout.flush()
|
|
1327
1715
|
|
|
1328
|
-
def initialize_instance_in_position_file(instance_name,
|
|
1329
|
-
"""Initialize
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1716
|
+
def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
1717
|
+
"""Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
|
|
1718
|
+
try:
|
|
1719
|
+
data = load_instance_position(instance_name)
|
|
1720
|
+
|
|
1721
|
+
defaults = {
|
|
1333
1722
|
"pos": 0,
|
|
1334
1723
|
"directory": str(Path.cwd()),
|
|
1335
|
-
"conversation_uuid": conversation_uuid or "unknown",
|
|
1336
1724
|
"last_tool": 0,
|
|
1337
1725
|
"last_tool_name": "unknown",
|
|
1338
1726
|
"last_stop": 0,
|
|
1339
1727
|
"last_permission_request": 0,
|
|
1728
|
+
"session_ids": [session_id] if session_id else [],
|
|
1340
1729
|
"transcript_path": "",
|
|
1341
|
-
"
|
|
1342
|
-
"
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
old_file = get_instance_file(instance_name)
|
|
1353
|
-
if old_file.exists():
|
|
1354
|
-
data = load_instance_position(instance_name)
|
|
1355
|
-
data["conversation_uuid"] = conversation_uuid
|
|
1356
|
-
save_instance_position(new_instance, data)
|
|
1357
|
-
old_file.unlink()
|
|
1358
|
-
return new_instance
|
|
1359
|
-
return instance_name
|
|
1730
|
+
"notification_message": "",
|
|
1731
|
+
"alias_announced": False
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
# Add missing fields (preserve existing)
|
|
1735
|
+
for key, value in defaults.items():
|
|
1736
|
+
data.setdefault(key, value)
|
|
1737
|
+
|
|
1738
|
+
return save_instance_position(instance_name, data)
|
|
1739
|
+
except Exception:
|
|
1740
|
+
return False
|
|
1360
1741
|
|
|
1361
1742
|
def update_instance_position(instance_name, update_fields):
|
|
1362
|
-
"""Update instance position
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
save_instance_position(instance_name, data)
|
|
1743
|
+
"""Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
|
|
1744
|
+
try:
|
|
1745
|
+
data = load_instance_position(instance_name)
|
|
1366
1746
|
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1747
|
+
if not data: # If file empty/missing, initialize first
|
|
1748
|
+
initialize_instance_in_position_file(instance_name)
|
|
1749
|
+
data = load_instance_position(instance_name)
|
|
1750
|
+
|
|
1751
|
+
data.update(update_fields)
|
|
1752
|
+
save_instance_position(instance_name, data)
|
|
1753
|
+
except PermissionError: # Expected on Windows during file locks, silently continue
|
|
1754
|
+
pass
|
|
1755
|
+
except Exception: # Other exceptions on Windows may also be file locking related
|
|
1756
|
+
if IS_WINDOWS:
|
|
1757
|
+
pass
|
|
1758
|
+
else:
|
|
1759
|
+
raise
|
|
1760
|
+
|
|
1761
|
+
def merge_instance_data(to_data, from_data):
|
|
1762
|
+
"""Merge instance data from from_data into to_data."""
|
|
1763
|
+
# Merge session_ids arrays with deduplication
|
|
1764
|
+
to_sessions = to_data.get('session_ids', [])
|
|
1765
|
+
from_sessions = from_data.get('session_ids', [])
|
|
1766
|
+
to_data['session_ids'] = list(dict.fromkeys(to_sessions + from_sessions))
|
|
1767
|
+
|
|
1768
|
+
# Update transient fields from source
|
|
1769
|
+
to_data['pid'] = os.getppid() # Always use current PID
|
|
1770
|
+
to_data['transcript_path'] = from_data.get('transcript_path', to_data.get('transcript_path', ''))
|
|
1771
|
+
|
|
1772
|
+
# Preserve maximum position
|
|
1773
|
+
to_data['pos'] = max(to_data.get('pos', 0), from_data.get('pos', 0))
|
|
1774
|
+
|
|
1775
|
+
# Update directory to most recent
|
|
1776
|
+
to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
|
|
1777
|
+
|
|
1778
|
+
# Update last activity timestamps to most recent
|
|
1779
|
+
to_data['last_tool'] = max(to_data.get('last_tool', 0), from_data.get('last_tool', 0))
|
|
1780
|
+
to_data['last_tool_name'] = from_data.get('last_tool_name', to_data.get('last_tool_name', 'unknown'))
|
|
1781
|
+
to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
|
|
1782
|
+
to_data['last_permission_request'] = max(
|
|
1783
|
+
to_data.get('last_permission_request', 0),
|
|
1784
|
+
from_data.get('last_permission_request', 0)
|
|
1785
|
+
)
|
|
1786
|
+
|
|
1787
|
+
# Preserve background mode if set
|
|
1788
|
+
to_data['background'] = to_data.get('background') or from_data.get('background')
|
|
1789
|
+
if from_data.get('background_log_file'):
|
|
1790
|
+
to_data['background_log_file'] = from_data['background_log_file']
|
|
1791
|
+
|
|
1792
|
+
return to_data
|
|
1793
|
+
|
|
1794
|
+
def terminate_process(pid, force=False):
|
|
1795
|
+
"""Cross-platform process termination"""
|
|
1796
|
+
try:
|
|
1797
|
+
if IS_WINDOWS:
|
|
1798
|
+
cmd = ['taskkill', '/PID', str(pid)]
|
|
1799
|
+
if force:
|
|
1800
|
+
cmd.insert(1, '/F')
|
|
1801
|
+
subprocess.run(cmd, capture_output=True, check=True)
|
|
1802
|
+
else:
|
|
1803
|
+
os.kill(pid, 9 if force else 15) # SIGKILL or SIGTERM
|
|
1804
|
+
return True
|
|
1805
|
+
except (ProcessLookupError, OSError, subprocess.CalledProcessError):
|
|
1806
|
+
return False # Process already dead
|
|
1807
|
+
|
|
1808
|
+
def merge_instance_immediately(from_name, to_name):
|
|
1809
|
+
"""Merge from_name into to_name with safety checks. Returns success message or error message."""
|
|
1810
|
+
if from_name == to_name:
|
|
1811
|
+
return ""
|
|
1812
|
+
|
|
1813
|
+
try:
|
|
1814
|
+
from_data = load_instance_position(from_name)
|
|
1815
|
+
to_data = load_instance_position(to_name)
|
|
1816
|
+
|
|
1817
|
+
# Check if target is active
|
|
1818
|
+
if to_data.get('pid'):
|
|
1819
|
+
if is_process_alive(to_data['pid']):
|
|
1820
|
+
return f"Cannot recover {to_name}: instance is active"
|
|
1821
|
+
# Process is dead, safe to merge
|
|
1822
|
+
|
|
1823
|
+
# Merge data using helper
|
|
1824
|
+
to_data = merge_instance_data(to_data, from_data)
|
|
1825
|
+
|
|
1826
|
+
# Save merged data - check for success
|
|
1827
|
+
if not save_instance_position(to_name, to_data):
|
|
1828
|
+
return f"Failed to save merged data for {to_name}"
|
|
1829
|
+
|
|
1830
|
+
# Cleanup source file only after successful save
|
|
1831
|
+
try:
|
|
1832
|
+
hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
|
|
1833
|
+
except:
|
|
1834
|
+
pass # Non-critical if cleanup fails
|
|
1835
|
+
|
|
1836
|
+
return f"[SUCCESS] ✓ Recovered: {from_name} → {to_name}"
|
|
1837
|
+
except Exception:
|
|
1838
|
+
return f"Failed to merge {from_name} into {to_name}"
|
|
1393
1839
|
|
|
1394
|
-
def check_and_show_first_use_help(instance_name):
|
|
1395
|
-
"""Check and show first-use help if needed (for Stop hook)"""
|
|
1396
|
-
help_text = get_first_use_text(instance_name)
|
|
1397
|
-
if help_text:
|
|
1398
|
-
output = {"decision": HOOK_DECISION_BLOCK, "reason": help_text}
|
|
1399
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
1400
|
-
sys.exit(EXIT_BLOCK)
|
|
1401
1840
|
|
|
1402
1841
|
# ==================== Command Functions ====================
|
|
1403
1842
|
|
|
@@ -1405,19 +1844,14 @@ def show_main_screen_header():
|
|
|
1405
1844
|
"""Show header for main screen"""
|
|
1406
1845
|
sys.stdout.write("\033[2J\033[H")
|
|
1407
1846
|
|
|
1408
|
-
log_file =
|
|
1847
|
+
log_file = hcom_path(LOG_FILE)
|
|
1409
1848
|
all_messages = []
|
|
1410
1849
|
if log_file.exists():
|
|
1411
1850
|
all_messages = parse_log_messages(log_file)
|
|
1412
|
-
message_count = len(all_messages)
|
|
1413
|
-
|
|
1414
|
-
print(f"\n{BOLD}{'='*50}{RESET}")
|
|
1415
|
-
print(f" {FG_CYAN}HCOM: global chat{RESET}")
|
|
1851
|
+
# message_count = len(all_messages)
|
|
1416
1852
|
|
|
1417
|
-
|
|
1418
|
-
print(f"
|
|
1419
|
-
print(f" {DIM}LOGS: {log_file} ({message_count} messages){RESET}")
|
|
1420
|
-
print(f"{BOLD}{'='*50}{RESET}\n")
|
|
1853
|
+
print(f"{BOLD}HCOM{RESET} LOGS")
|
|
1854
|
+
print(f"{DIM}{'─'*40}{RESET}\n")
|
|
1421
1855
|
|
|
1422
1856
|
return all_messages
|
|
1423
1857
|
|
|
@@ -1440,6 +1874,7 @@ Usage:
|
|
|
1440
1874
|
hcom open <agent> Launch named agent from .claude/agents/
|
|
1441
1875
|
hcom open --prefix <team> n Launch n instances with team prefix
|
|
1442
1876
|
hcom open --background Launch instances as background processes (-p also works)
|
|
1877
|
+
hcom open --claude-args "--model sonnet" Pass claude code CLI flags
|
|
1443
1878
|
hcom watch View conversation dashboard
|
|
1444
1879
|
hcom clear Clear and archive conversation
|
|
1445
1880
|
hcom cleanup Remove hooks from current directory
|
|
@@ -1456,9 +1891,9 @@ Automation:
|
|
|
1456
1891
|
hcom watch --wait [seconds] Wait for new messages (default 60s)
|
|
1457
1892
|
|
|
1458
1893
|
Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
|
|
1459
|
-
|
|
1460
|
-
# Additional help for AI assistants
|
|
1461
|
-
if not sys.stdin.isatty():
|
|
1894
|
+
|
|
1895
|
+
# Additional help for AI assistants
|
|
1896
|
+
if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
|
|
1462
1897
|
print("""
|
|
1463
1898
|
|
|
1464
1899
|
=== ADDITIONAL INFO ===
|
|
@@ -1497,22 +1932,28 @@ STATUS INDICATORS:
|
|
|
1497
1932
|
• ○ inactive - instance is timed out, disconnected, etc
|
|
1498
1933
|
|
|
1499
1934
|
CONFIG:
|
|
1500
|
-
Config file (persistent
|
|
1935
|
+
Config file (persistent): ~/.hcom/config.json
|
|
1501
1936
|
|
|
1502
1937
|
Key settings (full list in config.json):
|
|
1503
1938
|
terminal_mode: "new_window" (default) | "same_terminal" | "show_commands"
|
|
1504
1939
|
initial_prompt: "Say hi in chat", first_use_text: "Essential messages only..."
|
|
1505
1940
|
instance_hints: "text", cli_hints: "text" # Extra info for instances/CLI
|
|
1941
|
+
env_overrides: "custom environment variables for instances"
|
|
1506
1942
|
|
|
1507
1943
|
Temporary environment overrides for any setting (all caps & append HCOM_):
|
|
1508
|
-
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages
|
|
1944
|
+
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages received by instance
|
|
1509
1945
|
export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
|
|
1510
1946
|
|
|
1511
1947
|
EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
|
|
1512
|
-
with 'hcom watch --status'. Instances respond automatically in shared chat.
|
|
1513
|
-
|
|
1948
|
+
with 'hcom watch --status'. Instances respond automatically in shared chat.
|
|
1949
|
+
|
|
1950
|
+
Run 'claude --help' to see all claude code CLI flags.""")
|
|
1951
|
+
|
|
1514
1952
|
show_cli_hints(to_stderr=False)
|
|
1515
|
-
|
|
1953
|
+
else:
|
|
1954
|
+
if not IS_WINDOWS:
|
|
1955
|
+
print("\nFor additional info & examples: hcom --help | cat")
|
|
1956
|
+
|
|
1516
1957
|
return 0
|
|
1517
1958
|
|
|
1518
1959
|
def cmd_open(*args):
|
|
@@ -1520,7 +1961,15 @@ def cmd_open(*args):
|
|
|
1520
1961
|
try:
|
|
1521
1962
|
# Parse arguments
|
|
1522
1963
|
instances, prefix, claude_args, background = parse_open_args(list(args))
|
|
1523
|
-
|
|
1964
|
+
|
|
1965
|
+
# Extract resume sessionId if present
|
|
1966
|
+
resume_session_id = None
|
|
1967
|
+
if claude_args:
|
|
1968
|
+
for i, arg in enumerate(claude_args):
|
|
1969
|
+
if arg in ['--resume', '-r'] and i + 1 < len(claude_args):
|
|
1970
|
+
resume_session_id = claude_args[i + 1]
|
|
1971
|
+
break
|
|
1972
|
+
|
|
1524
1973
|
# Add -p flag and stream-json output for background mode if not already present
|
|
1525
1974
|
if background and '-p' not in claude_args and '--print' not in claude_args:
|
|
1526
1975
|
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
@@ -1541,33 +1990,41 @@ def cmd_open(*args):
|
|
|
1541
1990
|
print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
|
|
1542
1991
|
return 1
|
|
1543
1992
|
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
instances_dir
|
|
1993
|
+
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
1994
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1995
|
+
instances_dir.mkdir(exist_ok=True)
|
|
1547
1996
|
|
|
1548
1997
|
if not log_file.exists():
|
|
1549
1998
|
log_file.touch()
|
|
1550
|
-
if not instances_dir.exists():
|
|
1551
|
-
instances_dir.mkdir(exist_ok=True)
|
|
1552
1999
|
|
|
1553
2000
|
# Build environment variables for Claude instances
|
|
1554
2001
|
base_env = build_claude_env()
|
|
1555
|
-
|
|
2002
|
+
|
|
2003
|
+
# Pass resume sessionId to hooks (only for first instance if multiple)
|
|
2004
|
+
# This avoids conflicts when resuming with -n > 1
|
|
2005
|
+
if resume_session_id:
|
|
2006
|
+
if len(instances) > 1:
|
|
2007
|
+
print(f"Warning: --resume with {len(instances)} instances will only resume the first instance", file=sys.stderr)
|
|
2008
|
+
# Will be added to first instance env only
|
|
2009
|
+
|
|
1556
2010
|
# Add prefix-specific hints if provided
|
|
1557
2011
|
if prefix:
|
|
2012
|
+
base_env['HCOM_PREFIX'] = prefix
|
|
1558
2013
|
hint = f"To respond to {prefix} group: echo 'HCOM_SEND:@{prefix} message'"
|
|
1559
2014
|
base_env['HCOM_INSTANCE_HINTS'] = hint
|
|
1560
|
-
|
|
2015
|
+
|
|
1561
2016
|
first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
|
|
1562
2017
|
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
1563
2018
|
|
|
1564
2019
|
launched = 0
|
|
1565
2020
|
initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
|
|
1566
2021
|
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
for instance_type in instances:
|
|
2022
|
+
for idx, instance_type in enumerate(instances):
|
|
1570
2023
|
instance_env = base_env.copy()
|
|
2024
|
+
|
|
2025
|
+
# Add resume sessionId only to first instance when multiple instances
|
|
2026
|
+
if resume_session_id and idx == 0:
|
|
2027
|
+
instance_env['HCOM_RESUME_SESSION_ID'] = resume_session_id
|
|
1571
2028
|
|
|
1572
2029
|
# Mark background instances via environment with log filename
|
|
1573
2030
|
if background:
|
|
@@ -1587,6 +2044,8 @@ def cmd_open(*args):
|
|
|
1587
2044
|
# Agent instance
|
|
1588
2045
|
try:
|
|
1589
2046
|
agent_content, agent_config = resolve_agent(instance_type)
|
|
2047
|
+
# Mark this as a subagent instance for SessionStart hook
|
|
2048
|
+
instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
|
|
1590
2049
|
# Prepend agent instance awareness to system prompt
|
|
1591
2050
|
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
1592
2051
|
agent_content = agent_prefix + agent_content
|
|
@@ -1600,8 +2059,7 @@ def cmd_open(*args):
|
|
|
1600
2059
|
model=agent_model,
|
|
1601
2060
|
tools=agent_tools
|
|
1602
2061
|
)
|
|
1603
|
-
|
|
1604
|
-
temp_files_to_cleanup.append(temp_file)
|
|
2062
|
+
# Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
|
|
1605
2063
|
except (FileNotFoundError, ValueError) as e:
|
|
1606
2064
|
print(str(e), file=sys.stderr)
|
|
1607
2065
|
continue
|
|
@@ -1613,62 +2071,70 @@ def cmd_open(*args):
|
|
|
1613
2071
|
print(f"Background instance launched, log: {log_file}")
|
|
1614
2072
|
launched += 1
|
|
1615
2073
|
else:
|
|
1616
|
-
launch_terminal(claude_cmd, instance_env, cwd=os.getcwd())
|
|
1617
|
-
|
|
2074
|
+
if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
|
|
2075
|
+
launched += 1
|
|
1618
2076
|
except Exception as e:
|
|
1619
|
-
print(f"
|
|
2077
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
1620
2078
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
time.sleep(5) # Give terminals time to read the files
|
|
1625
|
-
for temp_file in temp_files_to_cleanup:
|
|
1626
|
-
try:
|
|
1627
|
-
os.unlink(temp_file)
|
|
1628
|
-
except:
|
|
1629
|
-
pass
|
|
1630
|
-
|
|
1631
|
-
cleanup_thread = threading.Thread(target=cleanup_temp_files)
|
|
1632
|
-
cleanup_thread.daemon = True
|
|
1633
|
-
cleanup_thread.start()
|
|
1634
|
-
|
|
2079
|
+
requested = len(instances)
|
|
2080
|
+
failed = requested - launched
|
|
2081
|
+
|
|
1635
2082
|
if launched == 0:
|
|
1636
|
-
print(format_error("No instances launched"), file=sys.stderr)
|
|
2083
|
+
print(format_error(f"No instances launched (0/{requested})"), file=sys.stderr)
|
|
1637
2084
|
return 1
|
|
1638
|
-
|
|
1639
|
-
#
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
"
|
|
1644
|
-
|
|
1645
|
-
if
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
2085
|
+
|
|
2086
|
+
# Show results
|
|
2087
|
+
if failed > 0:
|
|
2088
|
+
print(f"Launched {launched}/{requested} Claude instance{'s' if requested != 1 else ''} ({failed} failed)")
|
|
2089
|
+
else:
|
|
2090
|
+
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
2091
|
+
|
|
2092
|
+
# Auto-launch watch dashboard if configured and conditions are met
|
|
2093
|
+
terminal_mode = get_config_value('terminal_mode')
|
|
2094
|
+
auto_watch = get_config_value('auto_watch', True)
|
|
2095
|
+
|
|
2096
|
+
# Only auto-watch if ALL instances launched successfully
|
|
2097
|
+
if terminal_mode == 'new_window' and auto_watch and failed == 0 and is_interactive():
|
|
2098
|
+
# Show tips first if needed
|
|
2099
|
+
if prefix:
|
|
2100
|
+
print(f"\n • Send to {prefix} team: hcom send '@{prefix} message'")
|
|
2101
|
+
|
|
2102
|
+
# Clear transition message
|
|
2103
|
+
print("\nOpening hcom watch...")
|
|
2104
|
+
time.sleep(2) # Brief pause so user sees the message
|
|
2105
|
+
|
|
2106
|
+
# Launch interactive watch dashboard in current terminal
|
|
2107
|
+
return cmd_watch()
|
|
2108
|
+
else:
|
|
2109
|
+
tips = [
|
|
2110
|
+
"Run 'hcom watch' to view/send in conversation dashboard",
|
|
2111
|
+
]
|
|
2112
|
+
if prefix:
|
|
2113
|
+
tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
|
|
2114
|
+
|
|
2115
|
+
if tips:
|
|
2116
|
+
print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
|
|
2117
|
+
|
|
2118
|
+
# Show cli_hints if configured (non-interactive mode)
|
|
2119
|
+
if not is_interactive():
|
|
2120
|
+
show_cli_hints(to_stderr=False)
|
|
2121
|
+
|
|
2122
|
+
return 0
|
|
1657
2123
|
|
|
1658
2124
|
except ValueError as e:
|
|
1659
|
-
print(
|
|
2125
|
+
print(str(e), file=sys.stderr)
|
|
1660
2126
|
return 1
|
|
1661
2127
|
except Exception as e:
|
|
1662
|
-
print(
|
|
2128
|
+
print(str(e), file=sys.stderr)
|
|
1663
2129
|
return 1
|
|
1664
2130
|
|
|
1665
2131
|
def cmd_watch(*args):
|
|
1666
2132
|
"""View conversation dashboard"""
|
|
1667
|
-
log_file =
|
|
1668
|
-
instances_dir =
|
|
2133
|
+
log_file = hcom_path(LOG_FILE)
|
|
2134
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1669
2135
|
|
|
1670
2136
|
if not log_file.exists() and not instances_dir.exists():
|
|
1671
|
-
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2137
|
+
print(format_error("No conversation log found", "Run 'hcom open' first"), file=sys.stderr)
|
|
1672
2138
|
return 1
|
|
1673
2139
|
|
|
1674
2140
|
# Parse arguments
|
|
@@ -1721,19 +2187,23 @@ def cmd_watch(*args):
|
|
|
1721
2187
|
else:
|
|
1722
2188
|
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
1723
2189
|
|
|
2190
|
+
|
|
1724
2191
|
# Wait loop
|
|
1725
2192
|
start_time = time.time()
|
|
1726
2193
|
while time.time() - start_time < wait_timeout:
|
|
1727
|
-
if log_file.exists()
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
2194
|
+
if log_file.exists():
|
|
2195
|
+
current_size = log_file.stat().st_size
|
|
2196
|
+
new_messages = []
|
|
2197
|
+
if current_size > last_pos:
|
|
2198
|
+
# Capture new position BEFORE parsing (atomic)
|
|
2199
|
+
new_messages = parse_log_messages(log_file, last_pos)
|
|
1731
2200
|
if new_messages:
|
|
1732
2201
|
for msg in new_messages:
|
|
1733
2202
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
1734
|
-
last_pos =
|
|
2203
|
+
last_pos = current_size # Update only after successful processing
|
|
1735
2204
|
return 0 # Success - got new messages
|
|
1736
|
-
|
|
2205
|
+
if current_size > last_pos:
|
|
2206
|
+
last_pos = current_size # Update even if no messages (file grew but no complete messages yet)
|
|
1737
2207
|
time.sleep(0.1)
|
|
1738
2208
|
|
|
1739
2209
|
# Timeout message to stderr
|
|
@@ -1751,19 +2221,46 @@ def cmd_watch(*args):
|
|
|
1751
2221
|
show_cli_hints()
|
|
1752
2222
|
|
|
1753
2223
|
elif show_status:
|
|
1754
|
-
#
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
2224
|
+
# Build JSON output
|
|
2225
|
+
positions = load_all_positions()
|
|
2226
|
+
|
|
2227
|
+
instances = {}
|
|
2228
|
+
status_counts = {}
|
|
2229
|
+
|
|
2230
|
+
for name, data in positions.items():
|
|
2231
|
+
status, age = get_instance_status(data)
|
|
2232
|
+
instances[name] = {
|
|
2233
|
+
"status": status,
|
|
2234
|
+
"age": age.strip() if age else "",
|
|
2235
|
+
"directory": data.get("directory", "unknown"),
|
|
2236
|
+
"session_ids": data.get("session_ids", []),
|
|
2237
|
+
"last_tool": data.get("last_tool_name", "unknown"),
|
|
2238
|
+
"last_tool_time": data.get("last_tool", 0),
|
|
2239
|
+
"pid": data.get("pid"),
|
|
2240
|
+
"background": bool(data.get("background"))
|
|
2241
|
+
}
|
|
2242
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
|
2243
|
+
|
|
2244
|
+
# Get recent messages
|
|
2245
|
+
messages = []
|
|
2246
|
+
if log_file.exists():
|
|
2247
|
+
all_messages = parse_log_messages(log_file)
|
|
2248
|
+
messages = all_messages[-5:] if all_messages else []
|
|
2249
|
+
|
|
2250
|
+
# Output JSON
|
|
2251
|
+
output = {
|
|
2252
|
+
"instances": instances,
|
|
2253
|
+
"recent_messages": messages,
|
|
2254
|
+
"status_summary": status_counts,
|
|
2255
|
+
"log_file": str(log_file),
|
|
2256
|
+
"timestamp": datetime.now().isoformat()
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
print(json.dumps(output, indent=2))
|
|
1762
2260
|
show_cli_hints()
|
|
1763
2261
|
else:
|
|
1764
|
-
|
|
1765
|
-
print("
|
|
1766
|
-
print(" hcom send 'message' Send message to group", file=sys.stderr)
|
|
2262
|
+
print("No TTY - Automation usage:", file=sys.stderr)
|
|
2263
|
+
print(" hcom send 'message' Send message to chat", file=sys.stderr)
|
|
1767
2264
|
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
1768
2265
|
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
1769
2266
|
print(" hcom watch --wait Wait for new messages", file=sys.stderr)
|
|
@@ -1784,10 +2281,11 @@ def cmd_watch(*args):
|
|
|
1784
2281
|
all_messages = show_main_screen_header()
|
|
1785
2282
|
|
|
1786
2283
|
show_recent_messages(all_messages, limit=5)
|
|
1787
|
-
print(f"\n{DIM}
|
|
1788
|
-
|
|
2284
|
+
print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
|
|
2285
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
2286
|
+
|
|
1789
2287
|
# Print newline to ensure status starts on its own line
|
|
1790
|
-
print()
|
|
2288
|
+
# print()
|
|
1791
2289
|
|
|
1792
2290
|
current_status = get_status_summary()
|
|
1793
2291
|
update_status(f"{current_status}{status_suffix}")
|
|
@@ -1798,7 +2296,7 @@ def cmd_watch(*args):
|
|
|
1798
2296
|
try:
|
|
1799
2297
|
while True:
|
|
1800
2298
|
now = time.time()
|
|
1801
|
-
if now - last_status_update >
|
|
2299
|
+
if now - last_status_update > 0.1: # 100ms
|
|
1802
2300
|
current_status = get_status_summary()
|
|
1803
2301
|
|
|
1804
2302
|
# Only redraw if status text changed
|
|
@@ -1808,13 +2306,15 @@ def cmd_watch(*args):
|
|
|
1808
2306
|
|
|
1809
2307
|
last_status_update = now
|
|
1810
2308
|
|
|
1811
|
-
if log_file.exists()
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
2309
|
+
if log_file.exists():
|
|
2310
|
+
current_size = log_file.stat().st_size
|
|
2311
|
+
if current_size > last_pos:
|
|
2312
|
+
new_messages = parse_log_messages(log_file, last_pos)
|
|
2313
|
+
# Use the last known status for consistency
|
|
2314
|
+
status_line_text = f"{last_status}{status_suffix}"
|
|
2315
|
+
for msg in new_messages:
|
|
2316
|
+
log_line_with_status(format_message_line(msg), status_line_text)
|
|
2317
|
+
last_pos = current_size
|
|
1818
2318
|
|
|
1819
2319
|
# Check for keyboard input
|
|
1820
2320
|
ready_for_input = False
|
|
@@ -1835,7 +2335,8 @@ def cmd_watch(*args):
|
|
|
1835
2335
|
|
|
1836
2336
|
all_messages = show_main_screen_header()
|
|
1837
2337
|
show_recent_messages(all_messages)
|
|
1838
|
-
print(f"\n{DIM}
|
|
2338
|
+
print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
|
|
2339
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1839
2340
|
|
|
1840
2341
|
if log_file.exists():
|
|
1841
2342
|
last_pos = log_file.stat().st_size
|
|
@@ -1860,38 +2361,46 @@ def cmd_watch(*args):
|
|
|
1860
2361
|
|
|
1861
2362
|
def cmd_clear():
|
|
1862
2363
|
"""Clear and archive conversation"""
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
archive_folder
|
|
1867
|
-
|
|
2364
|
+
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
2365
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2366
|
+
archive_folder = hcom_path(ARCHIVE_DIR)
|
|
2367
|
+
archive_folder.mkdir(exist_ok=True)
|
|
2368
|
+
|
|
2369
|
+
# Clean up temp files from failed atomic writes
|
|
2370
|
+
if instances_dir.exists():
|
|
2371
|
+
deleted_count = sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
|
|
2372
|
+
if deleted_count > 0:
|
|
2373
|
+
print(f"Cleaned up {deleted_count} temp files")
|
|
2374
|
+
|
|
2375
|
+
# Clean up old script files (older than 24 hours)
|
|
2376
|
+
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
2377
|
+
if scripts_dir.exists():
|
|
2378
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2379
|
+
script_count = sum(1 for f in scripts_dir.glob('*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
|
|
2380
|
+
if script_count > 0:
|
|
2381
|
+
print(f"Cleaned up {script_count} old script files")
|
|
2382
|
+
|
|
1868
2383
|
# Check if hcom files exist
|
|
1869
2384
|
if not log_file.exists() and not instances_dir.exists():
|
|
1870
2385
|
print("No hcom conversation to clear")
|
|
1871
2386
|
return 0
|
|
1872
|
-
|
|
1873
|
-
# Generate archive timestamp
|
|
1874
|
-
timestamp = get_archive_timestamp()
|
|
1875
|
-
|
|
1876
|
-
# Ensure archive folder exists
|
|
1877
|
-
archive_folder.mkdir(exist_ok=True)
|
|
1878
|
-
|
|
2387
|
+
|
|
1879
2388
|
# Archive existing files if they have content
|
|
2389
|
+
timestamp = get_archive_timestamp()
|
|
1880
2390
|
archived = False
|
|
1881
|
-
|
|
2391
|
+
|
|
1882
2392
|
try:
|
|
1883
|
-
# Create session archive folder if we have content to archive
|
|
1884
2393
|
has_log = log_file.exists() and log_file.stat().st_size > 0
|
|
1885
2394
|
has_instances = instances_dir.exists() and any(instances_dir.glob('*.json'))
|
|
1886
2395
|
|
|
1887
2396
|
if has_log or has_instances:
|
|
1888
2397
|
# Create session archive folder with timestamp
|
|
1889
|
-
session_archive =
|
|
2398
|
+
session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
|
|
1890
2399
|
session_archive.mkdir(exist_ok=True)
|
|
1891
2400
|
|
|
1892
2401
|
# Archive log file
|
|
1893
2402
|
if has_log:
|
|
1894
|
-
archive_log = session_archive /
|
|
2403
|
+
archive_log = session_archive / LOG_FILE
|
|
1895
2404
|
log_file.rename(archive_log)
|
|
1896
2405
|
archived = True
|
|
1897
2406
|
elif log_file.exists():
|
|
@@ -1899,10 +2408,10 @@ def cmd_clear():
|
|
|
1899
2408
|
|
|
1900
2409
|
# Archive instances
|
|
1901
2410
|
if has_instances:
|
|
1902
|
-
archive_instances = session_archive /
|
|
2411
|
+
archive_instances = session_archive / INSTANCES_DIR
|
|
1903
2412
|
archive_instances.mkdir(exist_ok=True)
|
|
1904
2413
|
|
|
1905
|
-
# Move json files only
|
|
2414
|
+
# Move json files only
|
|
1906
2415
|
for f in instances_dir.glob('*.json'):
|
|
1907
2416
|
f.rename(archive_instances / f.name)
|
|
1908
2417
|
|
|
@@ -1916,7 +2425,7 @@ def cmd_clear():
|
|
|
1916
2425
|
|
|
1917
2426
|
log_file.touch()
|
|
1918
2427
|
clear_all_positions()
|
|
1919
|
-
|
|
2428
|
+
|
|
1920
2429
|
if archived:
|
|
1921
2430
|
print(f"Archived to archive/session-{timestamp}/")
|
|
1922
2431
|
print("Started fresh hcom conversation log")
|
|
@@ -1939,20 +2448,24 @@ def cleanup_directory_hooks(directory):
|
|
|
1939
2448
|
|
|
1940
2449
|
try:
|
|
1941
2450
|
# Load existing settings
|
|
1942
|
-
|
|
1943
|
-
|
|
2451
|
+
settings = read_file_with_retry(
|
|
2452
|
+
settings_path,
|
|
2453
|
+
lambda f: json.load(f),
|
|
2454
|
+
default=None
|
|
2455
|
+
)
|
|
2456
|
+
if not settings:
|
|
2457
|
+
return 1, "Cannot read Claude settings"
|
|
1944
2458
|
|
|
1945
|
-
# Check if any hcom hooks exist
|
|
1946
2459
|
hooks_found = False
|
|
1947
2460
|
|
|
1948
|
-
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
1949
|
-
for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2461
|
+
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2462
|
+
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
1950
2463
|
|
|
1951
2464
|
_remove_hcom_hooks_from_settings(settings)
|
|
1952
2465
|
|
|
1953
2466
|
# Check if any were removed
|
|
1954
|
-
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
1955
|
-
for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2467
|
+
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2468
|
+
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
1956
2469
|
if new_hook_count < original_hook_count:
|
|
1957
2470
|
hooks_found = True
|
|
1958
2471
|
|
|
@@ -1977,56 +2490,49 @@ def cleanup_directory_hooks(directory):
|
|
|
1977
2490
|
|
|
1978
2491
|
def cmd_kill(*args):
|
|
1979
2492
|
"""Kill instances by name or all with --all"""
|
|
1980
|
-
|
|
1981
|
-
|
|
2493
|
+
|
|
1982
2494
|
instance_name = args[0] if args and args[0] != '--all' else None
|
|
1983
2495
|
positions = load_all_positions() if not instance_name else {instance_name: load_instance_position(instance_name)}
|
|
1984
|
-
|
|
2496
|
+
|
|
1985
2497
|
# Filter to instances with PIDs (any instance that's running)
|
|
1986
2498
|
targets = [(name, data) for name, data in positions.items() if data.get('pid')]
|
|
1987
|
-
|
|
2499
|
+
|
|
1988
2500
|
if not targets:
|
|
1989
2501
|
print(f"No running process found for {instance_name}" if instance_name else "No running instances found")
|
|
1990
2502
|
return 1 if instance_name else 0
|
|
1991
|
-
|
|
2503
|
+
|
|
1992
2504
|
killed_count = 0
|
|
1993
2505
|
for target_name, target_data in targets:
|
|
1994
|
-
# Get status and type info before killing
|
|
1995
2506
|
status, age = get_instance_status(target_data)
|
|
1996
2507
|
instance_type = "background" if target_data.get('background') else "foreground"
|
|
1997
|
-
|
|
2508
|
+
|
|
1998
2509
|
pid = int(target_data['pid'])
|
|
1999
2510
|
try:
|
|
2000
|
-
#
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
# Wait
|
|
2511
|
+
# Try graceful termination first
|
|
2512
|
+
terminate_process(pid, force=False)
|
|
2513
|
+
|
|
2514
|
+
# Wait for process to exit gracefully
|
|
2004
2515
|
for _ in range(20):
|
|
2005
|
-
time.sleep(
|
|
2006
|
-
|
|
2007
|
-
os.kill(pid, 0) # Check if process still exists
|
|
2008
|
-
except ProcessLookupError:
|
|
2516
|
+
time.sleep(KILL_CHECK_INTERVAL)
|
|
2517
|
+
if not is_process_alive(pid):
|
|
2009
2518
|
# Process terminated successfully
|
|
2010
2519
|
break
|
|
2011
2520
|
else:
|
|
2012
|
-
# Process didn't die from
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
except ProcessLookupError:
|
|
2017
|
-
pass # Already dead
|
|
2018
|
-
|
|
2521
|
+
# Process didn't die from graceful attempt, force kill
|
|
2522
|
+
terminate_process(pid, force=True)
|
|
2523
|
+
time.sleep(0.1)
|
|
2524
|
+
|
|
2019
2525
|
print(f"Killed {target_name} ({instance_type}, {status}{age}, PID {pid})")
|
|
2020
2526
|
killed_count += 1
|
|
2021
|
-
except (
|
|
2022
|
-
print(f"Process {pid}
|
|
2023
|
-
|
|
2527
|
+
except (TypeError, ValueError) as e:
|
|
2528
|
+
print(f"Process {pid} invalid: {e}")
|
|
2529
|
+
|
|
2024
2530
|
# Mark instance as killed
|
|
2025
2531
|
update_instance_position(target_name, {'pid': None})
|
|
2026
|
-
|
|
2532
|
+
|
|
2027
2533
|
if not instance_name:
|
|
2028
2534
|
print(f"Killed {killed_count} instance(s)")
|
|
2029
|
-
|
|
2535
|
+
|
|
2030
2536
|
return 0
|
|
2031
2537
|
|
|
2032
2538
|
def cmd_cleanup(*args):
|
|
@@ -2042,7 +2548,7 @@ def cmd_cleanup(*args):
|
|
|
2042
2548
|
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
2043
2549
|
directories.add(instance_data['directory'])
|
|
2044
2550
|
except Exception as e:
|
|
2045
|
-
print(
|
|
2551
|
+
print(f"Warning: Could not read current instances: {e}")
|
|
2046
2552
|
|
|
2047
2553
|
if not directories:
|
|
2048
2554
|
print("No directories found in current hcom tracking")
|
|
@@ -2060,17 +2566,10 @@ def cmd_cleanup(*args):
|
|
|
2060
2566
|
continue
|
|
2061
2567
|
|
|
2062
2568
|
print(f"\nChecking {directory}...")
|
|
2063
|
-
|
|
2064
|
-
# Check if settings file exists
|
|
2065
|
-
settings_path = Path(directory) / '.claude' / 'settings.local.json'
|
|
2066
|
-
if not settings_path.exists():
|
|
2067
|
-
print(" No Claude settings found")
|
|
2068
|
-
already_clean += 1
|
|
2069
|
-
continue
|
|
2070
|
-
|
|
2569
|
+
|
|
2071
2570
|
exit_code, message = cleanup_directory_hooks(Path(directory))
|
|
2072
2571
|
if exit_code == 0:
|
|
2073
|
-
if "No hcom hooks found" in message:
|
|
2572
|
+
if "No hcom hooks found" in message or "No Claude settings found" in message:
|
|
2074
2573
|
already_clean += 1
|
|
2075
2574
|
print(f" {message}")
|
|
2076
2575
|
else:
|
|
@@ -2096,8 +2595,8 @@ def cmd_cleanup(*args):
|
|
|
2096
2595
|
def cmd_send(message):
|
|
2097
2596
|
"""Send message to hcom"""
|
|
2098
2597
|
# Check if hcom files exist
|
|
2099
|
-
log_file =
|
|
2100
|
-
instances_dir =
|
|
2598
|
+
log_file = hcom_path(LOG_FILE)
|
|
2599
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2101
2600
|
|
|
2102
2601
|
if not log_file.exists() and not instances_dir.exists():
|
|
2103
2602
|
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
@@ -2106,7 +2605,7 @@ def cmd_send(message):
|
|
|
2106
2605
|
# Validate message
|
|
2107
2606
|
error = validate_message(message)
|
|
2108
2607
|
if error:
|
|
2109
|
-
print(
|
|
2608
|
+
print(error, file=sys.stderr)
|
|
2110
2609
|
return 1
|
|
2111
2610
|
|
|
2112
2611
|
# Check for unmatched mentions (minimal warning)
|
|
@@ -2139,12 +2638,6 @@ def cmd_send(message):
|
|
|
2139
2638
|
|
|
2140
2639
|
# ==================== Hook Functions ====================
|
|
2141
2640
|
|
|
2142
|
-
def update_instance_with_pid(updates, existing_data):
|
|
2143
|
-
"""Add PID to updates if not already set in existing data"""
|
|
2144
|
-
if not existing_data.get('pid'):
|
|
2145
|
-
updates['pid'] = os.getppid() # Parent PID is the Claude process
|
|
2146
|
-
return updates
|
|
2147
|
-
|
|
2148
2641
|
def format_hook_messages(messages, instance_name):
|
|
2149
2642
|
"""Format messages for hook feedback"""
|
|
2150
2643
|
if len(messages) == 1:
|
|
@@ -2153,305 +2646,514 @@ def format_hook_messages(messages, instance_name):
|
|
|
2153
2646
|
else:
|
|
2154
2647
|
parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
|
|
2155
2648
|
reason = f"[{len(messages)} new messages] | " + " | ".join(parts)
|
|
2156
|
-
|
|
2649
|
+
|
|
2650
|
+
# Check alias announcement
|
|
2651
|
+
instance_data = load_instance_position(instance_name)
|
|
2652
|
+
if not instance_data.get('alias_announced', False) and not instance_name.endswith('claude'):
|
|
2653
|
+
reason = f"{reason} | [Alias assigned: {instance_name}] <Your hcom chat alias is {instance_name}. You can at-mention others in hcom chat by their alias to DM them. (alias1 → alias2 means alias1 sent the message to the entire group, if there is an at symbol in the message then it is targeted)>"
|
|
2654
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
2655
|
+
|
|
2157
2656
|
# Only append instance_hints to messages (first_use_text is handled separately)
|
|
2158
2657
|
instance_hints = get_config_value('instance_hints', '')
|
|
2159
2658
|
if instance_hints:
|
|
2160
2659
|
reason = f"{reason} | [{instance_hints}]"
|
|
2161
|
-
|
|
2660
|
+
|
|
2162
2661
|
return reason
|
|
2163
2662
|
|
|
2164
|
-
def
|
|
2165
|
-
"""
|
|
2166
|
-
|
|
2167
|
-
if os.
|
|
2168
|
-
|
|
2169
|
-
|
|
2663
|
+
def get_pending_tools(transcript_path, max_lines=100):
|
|
2664
|
+
"""Parse transcript to find tool_use IDs without matching tool_results.
|
|
2665
|
+
Returns count of pending tools."""
|
|
2666
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
2667
|
+
return 0
|
|
2668
|
+
|
|
2669
|
+
tool_uses = set()
|
|
2670
|
+
tool_results = set()
|
|
2671
|
+
|
|
2170
2672
|
try:
|
|
2171
|
-
# Read
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
#
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2673
|
+
# Read last N lines efficiently
|
|
2674
|
+
with open(transcript_path, 'rb') as f:
|
|
2675
|
+
# Seek to end and read backwards
|
|
2676
|
+
f.seek(0, 2) # Go to end
|
|
2677
|
+
file_size = f.tell()
|
|
2678
|
+
read_size = min(file_size, max_lines * 500) # Assume ~500 bytes per line
|
|
2679
|
+
f.seek(max(0, file_size - read_size))
|
|
2680
|
+
recent_content = f.read().decode('utf-8', errors='ignore')
|
|
2681
|
+
|
|
2682
|
+
# Parse line by line (handle both Unix \n and Windows \r\n)
|
|
2683
|
+
for line in recent_content.splitlines():
|
|
2684
|
+
if not line.strip():
|
|
2685
|
+
continue
|
|
2686
|
+
try:
|
|
2687
|
+
data = json.loads(line)
|
|
2688
|
+
|
|
2689
|
+
# Check for tool_use blocks in assistant messages
|
|
2690
|
+
if data.get('type') == 'assistant':
|
|
2691
|
+
content = data.get('message', {}).get('content', [])
|
|
2692
|
+
if isinstance(content, list):
|
|
2693
|
+
for item in content:
|
|
2694
|
+
if isinstance(item, dict) and item.get('type') == 'tool_use':
|
|
2695
|
+
tool_id = item.get('id')
|
|
2696
|
+
if tool_id:
|
|
2697
|
+
tool_uses.add(tool_id)
|
|
2698
|
+
|
|
2699
|
+
# Check for tool_results in user messages
|
|
2700
|
+
elif data.get('type') == 'user':
|
|
2701
|
+
content = data.get('message', {}).get('content', [])
|
|
2702
|
+
if isinstance(content, list):
|
|
2703
|
+
for item in content:
|
|
2704
|
+
if isinstance(item, dict) and item.get('type') == 'tool_result':
|
|
2705
|
+
tool_id = item.get('tool_use_id')
|
|
2706
|
+
if tool_id:
|
|
2707
|
+
tool_results.add(tool_id)
|
|
2708
|
+
except Exception as e:
|
|
2709
|
+
continue
|
|
2710
|
+
|
|
2711
|
+
# Return count of pending tools
|
|
2712
|
+
pending = tool_uses - tool_results
|
|
2713
|
+
return len(pending)
|
|
2714
|
+
except Exception as e:
|
|
2715
|
+
return 0 # On any error, assume no pending tools
|
|
2716
|
+
|
|
2717
|
+
# ==================== Hook Handlers ====================
|
|
2718
|
+
|
|
2719
|
+
def init_hook_context(hook_data):
|
|
2720
|
+
"""Initialize instance context - shared by post/stop/notify hooks"""
|
|
2721
|
+
session_id = hook_data.get('session_id', '')
|
|
2722
|
+
transcript_path = hook_data.get('transcript_path', '')
|
|
2723
|
+
prefix = os.environ.get('HCOM_PREFIX')
|
|
2724
|
+
|
|
2725
|
+
# Check if this is a resume operation
|
|
2726
|
+
resume_session_id = os.environ.get('HCOM_RESUME_SESSION_ID')
|
|
2727
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2728
|
+
instance_name = None
|
|
2729
|
+
merged_state = None
|
|
2730
|
+
|
|
2731
|
+
# First, try to find existing instance by resume sessionId
|
|
2732
|
+
if resume_session_id and instances_dir.exists():
|
|
2733
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
2734
|
+
try:
|
|
2735
|
+
data = load_instance_position(instance_file.stem)
|
|
2736
|
+
# Check if resume_session_id matches any in the session_ids array
|
|
2737
|
+
old_session_ids = data.get('session_ids', [])
|
|
2738
|
+
if resume_session_id in old_session_ids:
|
|
2739
|
+
# Found the instance! Keep the same name
|
|
2740
|
+
instance_name = instance_file.stem
|
|
2741
|
+
merged_state = data
|
|
2742
|
+
# Append new session_id to array, update transcript_path to current
|
|
2743
|
+
if session_id and session_id not in old_session_ids:
|
|
2744
|
+
merged_state.setdefault('session_ids', old_session_ids).append(session_id)
|
|
2745
|
+
if transcript_path:
|
|
2746
|
+
merged_state['transcript_path'] = transcript_path
|
|
2747
|
+
break
|
|
2748
|
+
except:
|
|
2749
|
+
continue
|
|
2750
|
+
|
|
2751
|
+
# Check if current session exists in any instance's session_ids array
|
|
2752
|
+
# This maintains identity after implicit HCOM_RESUME
|
|
2753
|
+
if not instance_name and session_id and instances_dir.exists():
|
|
2754
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
2755
|
+
try:
|
|
2756
|
+
data = load_instance_position(instance_file.stem)
|
|
2757
|
+
if session_id in data.get('session_ids', []):
|
|
2758
|
+
instance_name = instance_file.stem
|
|
2759
|
+
merged_state = data
|
|
2760
|
+
break
|
|
2761
|
+
except:
|
|
2762
|
+
continue
|
|
2763
|
+
|
|
2764
|
+
# If not found or not resuming, generate new name from session_id
|
|
2765
|
+
if not instance_name:
|
|
2766
|
+
instance_name = get_display_name(session_id, prefix)
|
|
2767
|
+
|
|
2768
|
+
# PID deduplication: Clean up any stale instance files with same PID
|
|
2769
|
+
# Always run to clean up temp instances even after implicit resume
|
|
2770
|
+
parent_pid = os.getppid()
|
|
2771
|
+
if instances_dir.exists():
|
|
2772
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
2773
|
+
if instance_file.stem != instance_name: # Skip current instance
|
|
2774
|
+
try:
|
|
2775
|
+
data = load_instance_position(instance_file.stem)
|
|
2776
|
+
if data.get('pid') == parent_pid:
|
|
2777
|
+
# Found duplicate with same PID - merge and delete
|
|
2778
|
+
if not merged_state:
|
|
2779
|
+
merged_state = data
|
|
2780
|
+
else:
|
|
2781
|
+
# Merge useful fields from duplicate
|
|
2782
|
+
merged_state = merge_instance_data(merged_state, data)
|
|
2783
|
+
instance_file.unlink() # Delete the duplicate file
|
|
2784
|
+
# Don't break - could have multiple duplicates with same PID
|
|
2785
|
+
except:
|
|
2786
|
+
continue
|
|
2787
|
+
|
|
2788
|
+
# Save migrated data if we have it
|
|
2789
|
+
if merged_state:
|
|
2790
|
+
save_instance_position(instance_name, merged_state)
|
|
2791
|
+
|
|
2792
|
+
initialize_instance_in_position_file(instance_name, session_id)
|
|
2793
|
+
existing_data = load_instance_position(instance_name)
|
|
2794
|
+
|
|
2795
|
+
# Prepare updates - use array for session_ids, single field for transcript_path
|
|
2796
|
+
updates = {
|
|
2797
|
+
'directory': str(Path.cwd()),
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
# Update session_ids array if we have a new session_id
|
|
2801
|
+
if session_id:
|
|
2802
|
+
current_session_ids = existing_data.get('session_ids', [])
|
|
2803
|
+
if session_id not in current_session_ids:
|
|
2804
|
+
current_session_ids.append(session_id)
|
|
2805
|
+
updates['session_ids'] = current_session_ids
|
|
2806
|
+
|
|
2807
|
+
# Update transcript_path to current
|
|
2808
|
+
if transcript_path:
|
|
2809
|
+
updates['transcript_path'] = transcript_path
|
|
2810
|
+
|
|
2811
|
+
# Always update PID to current (fixes stale PID on implicit resume)
|
|
2812
|
+
updates['pid'] = os.getppid()
|
|
2813
|
+
|
|
2814
|
+
# Add background status if applicable
|
|
2815
|
+
bg_env = os.environ.get('HCOM_BACKGROUND')
|
|
2816
|
+
if bg_env:
|
|
2817
|
+
updates['background'] = True
|
|
2818
|
+
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
2819
|
+
|
|
2820
|
+
return instance_name, updates, existing_data
|
|
2821
|
+
|
|
2822
|
+
def extract_hcom_command(command, prefix='HCOM_SEND'):
|
|
2823
|
+
"""Extract command payload with quote stripping"""
|
|
2824
|
+
marker = f'{prefix}:'
|
|
2825
|
+
if marker not in command:
|
|
2826
|
+
return None
|
|
2827
|
+
|
|
2828
|
+
parts = command.split(marker, 1)
|
|
2829
|
+
if len(parts) <= 1:
|
|
2830
|
+
return None
|
|
2831
|
+
|
|
2832
|
+
payload = parts[1].strip()
|
|
2833
|
+
|
|
2834
|
+
# Complex quote stripping logic (preserves exact behavior)
|
|
2835
|
+
if len(payload) >= 2 and \
|
|
2836
|
+
((payload[0] == '"' and payload[-1] == '"') or \
|
|
2837
|
+
(payload[0] == "'" and payload[-1] == "'")):
|
|
2838
|
+
payload = payload[1:-1]
|
|
2839
|
+
elif payload and payload[-1] in '"\'':
|
|
2840
|
+
payload = payload[:-1]
|
|
2841
|
+
|
|
2842
|
+
return payload if payload else None
|
|
2843
|
+
|
|
2844
|
+
def _sanitize_alias(alias):
|
|
2845
|
+
"""Sanitize extracted alias: strip quotes/backticks, stop at first invalid char/whitespace."""
|
|
2846
|
+
alias = alias.strip()
|
|
2847
|
+
# Strip wrapping quotes/backticks iteratively
|
|
2848
|
+
for _ in range(3):
|
|
2849
|
+
if len(alias) >= 2 and alias[0] == alias[-1] and alias[0] in ['"', "'", '`']:
|
|
2850
|
+
alias = alias[1:-1].strip()
|
|
2851
|
+
elif alias and alias[-1] in ['"', "'", '`']:
|
|
2852
|
+
alias = alias[:-1].strip()
|
|
2853
|
+
else:
|
|
2854
|
+
break
|
|
2855
|
+
# Stop at first whitespace or invalid char
|
|
2856
|
+
alias = re.split(r'[^A-Za-z0-9\-_]', alias)[0]
|
|
2857
|
+
return alias
|
|
2858
|
+
|
|
2859
|
+
def extract_resume_alias(command):
|
|
2860
|
+
"""Extract resume alias safely.
|
|
2861
|
+
Priority:
|
|
2862
|
+
1) HCOM_SEND payload that starts with RESUME:alias
|
|
2863
|
+
2) Bare HCOM_RESUME:alias (only when not embedded in HCOM_SEND payload)
|
|
2864
|
+
"""
|
|
2865
|
+
# 1) Prefer explicit HCOM_SEND payload
|
|
2866
|
+
payload = extract_hcom_command(command)
|
|
2867
|
+
if payload:
|
|
2868
|
+
cand = payload.strip()
|
|
2869
|
+
if cand.startswith('RESUME:'):
|
|
2870
|
+
alias_raw = cand.split(':', 1)[1].strip()
|
|
2871
|
+
alias = _sanitize_alias(alias_raw)
|
|
2872
|
+
return alias or None
|
|
2873
|
+
# If payload contains text like "HCOM_RESUME:alias" but not at start,
|
|
2874
|
+
# ignore to prevent alias hijack from normal messages
|
|
2875
|
+
|
|
2876
|
+
# 2) Fallback: bare HCOM_RESUME when not using HCOM_SEND
|
|
2877
|
+
alias_raw = extract_hcom_command(command, 'HCOM_RESUME')
|
|
2878
|
+
if alias_raw:
|
|
2879
|
+
alias = _sanitize_alias(alias_raw)
|
|
2880
|
+
return alias or None
|
|
2881
|
+
return None
|
|
2882
|
+
|
|
2883
|
+
def compute_decision_for_visibility(transcript_path):
|
|
2884
|
+
"""Compute hook decision based on pending tools to prevent API 400 errors."""
|
|
2885
|
+
pending_tools = get_pending_tools(transcript_path)
|
|
2886
|
+
decision = None if pending_tools > 0 else HOOK_DECISION_BLOCK
|
|
2887
|
+
|
|
2888
|
+
return decision
|
|
2889
|
+
|
|
2890
|
+
def emit_resume_feedback(status, instance_name, transcript_path):
|
|
2891
|
+
"""Emit formatted resume feedback with appropriate visibility."""
|
|
2892
|
+
# Build formatted feedback based on success/failure
|
|
2893
|
+
if status.startswith("[SUCCESS]"):
|
|
2894
|
+
reason = f"[{status}]{HCOM_FORMAT_INSTRUCTIONS}"
|
|
2895
|
+
else:
|
|
2896
|
+
reason = f"[⚠️ {status} - your alias is: {instance_name}]{HCOM_FORMAT_INSTRUCTIONS}"
|
|
2897
|
+
|
|
2898
|
+
# Compute decision based on pending tools
|
|
2899
|
+
decision = compute_decision_for_visibility(transcript_path)
|
|
2900
|
+
|
|
2901
|
+
# Emit response
|
|
2902
|
+
emit_hook_response(reason, decision=decision)
|
|
2903
|
+
|
|
2904
|
+
def handle_pretooluse(hook_data):
|
|
2905
|
+
"""Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
|
|
2906
|
+
# Check if this is an HCOM_SEND command that needs auto-approval
|
|
2907
|
+
tool_name = hook_data.get('tool_name', '')
|
|
2908
|
+
if tool_name == 'Bash':
|
|
2909
|
+
command = hook_data.get('tool_input', {}).get('command', '')
|
|
2910
|
+
if 'HCOM_SEND:' in command or extract_resume_alias(command):
|
|
2911
|
+
# Check if other tools are pending - prevent API 400 errors
|
|
2912
|
+
transcript_path = hook_data.get('transcript_path', '')
|
|
2913
|
+
# Subtract 1 because the current tool is already in transcript but not actually pending
|
|
2914
|
+
pending_count = max(0, get_pending_tools(transcript_path) - 1)
|
|
2915
|
+
|
|
2916
|
+
if pending_count > 0:
|
|
2917
|
+
# Deny execution to prevent injecting content between tool_use/tool_result
|
|
2918
|
+
output = {
|
|
2919
|
+
"hookSpecificOutput": {
|
|
2920
|
+
"hookEventName": "PreToolUse",
|
|
2921
|
+
"permissionDecision": "deny",
|
|
2922
|
+
"permissionDecisionReason": f"Waiting - {pending_count} tool(s) still executing. Try again in a moment."
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
else:
|
|
2926
|
+
# Safe to proceed
|
|
2927
|
+
output = {
|
|
2928
|
+
"hookSpecificOutput": {
|
|
2929
|
+
"hookEventName": "PreToolUse",
|
|
2930
|
+
"permissionDecision": "allow",
|
|
2931
|
+
"permissionDecisionReason": "HCOM_SEND command auto-approved"
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
2935
|
+
sys.exit(EXIT_SUCCESS)
|
|
2936
|
+
|
|
2937
|
+
def handle_posttooluse(hook_data, instance_name, updates):
|
|
2938
|
+
"""Handle PostToolUse hook - extract and deliver messages"""
|
|
2939
|
+
updates['last_tool'] = int(time.time())
|
|
2940
|
+
updates['last_tool_name'] = hook_data.get('tool_name', 'unknown')
|
|
2941
|
+
update_instance_position(instance_name, updates)
|
|
2942
|
+
|
|
2943
|
+
# Check for HCOM_SEND in Bash commands
|
|
2944
|
+
sent_reason = None
|
|
2945
|
+
if hook_data.get('tool_name') == 'Bash':
|
|
2946
|
+
command = hook_data.get('tool_input', {}).get('command', '')
|
|
2947
|
+
|
|
2948
|
+
# Check for RESUME command first (safe extraction)
|
|
2949
|
+
alias = extract_resume_alias(command)
|
|
2950
|
+
if alias:
|
|
2951
|
+
status = merge_instance_immediately(instance_name, alias)
|
|
2952
|
+
|
|
2953
|
+
# If names match, find and merge any duplicate with same PID
|
|
2954
|
+
if not status and instance_name == alias:
|
|
2955
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2956
|
+
parent_pid = os.getppid()
|
|
2957
|
+
if instances_dir.exists():
|
|
2958
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
2959
|
+
if instance_file.stem != instance_name:
|
|
2960
|
+
try:
|
|
2961
|
+
data = load_instance_position(instance_file.stem)
|
|
2962
|
+
if data.get('pid') == parent_pid:
|
|
2963
|
+
# Found duplicate - merge it
|
|
2964
|
+
status = merge_instance_immediately(instance_file.stem, instance_name)
|
|
2965
|
+
if status:
|
|
2966
|
+
status = f"[SUCCESS] ✓ Merged duplicate: {instance_file.stem} → {instance_name}"
|
|
2967
|
+
break
|
|
2968
|
+
except:
|
|
2969
|
+
continue
|
|
2970
|
+
|
|
2971
|
+
if not status:
|
|
2972
|
+
status = f"[SUCCESS] ✓ Already using alias {alias}"
|
|
2973
|
+
elif not status:
|
|
2974
|
+
status = f"[WARNING] ⚠️ Merge failed: {instance_name} → {alias}"
|
|
2975
|
+
|
|
2976
|
+
if status:
|
|
2977
|
+
transcript_path = hook_data.get('transcript_path', '')
|
|
2978
|
+
emit_resume_feedback(status, instance_name, transcript_path)
|
|
2979
|
+
return # Don't process RESUME as regular message
|
|
2980
|
+
|
|
2981
|
+
# Normal message handling
|
|
2982
|
+
message = extract_hcom_command(command) # defaults to HCOM_SEND
|
|
2983
|
+
if message:
|
|
2984
|
+
error = validate_message(message)
|
|
2985
|
+
if error:
|
|
2986
|
+
emit_hook_response(f"❌ {error}")
|
|
2987
|
+
send_message(instance_name, message)
|
|
2988
|
+
sent_reason = "[✓ Sent]"
|
|
2989
|
+
|
|
2990
|
+
# Check for pending tools in transcript
|
|
2991
|
+
transcript_path = hook_data.get('transcript_path', '')
|
|
2992
|
+
pending_count = get_pending_tools(transcript_path)
|
|
2993
|
+
|
|
2994
|
+
# Build response if needed
|
|
2995
|
+
response_reason = None
|
|
2996
|
+
|
|
2997
|
+
# Only deliver messages when all tools are complete (pending_count == 0)
|
|
2998
|
+
if pending_count == 0:
|
|
2247
2999
|
messages = get_new_messages(instance_name)
|
|
2248
|
-
|
|
2249
|
-
# Limit messages to max_messages_per_delivery
|
|
2250
3000
|
if messages:
|
|
2251
|
-
|
|
2252
|
-
messages = messages[:max_messages]
|
|
2253
|
-
|
|
2254
|
-
if messages and sent_reason:
|
|
2255
|
-
# Both sent and received
|
|
2256
|
-
reason = f"{sent_reason} | {format_hook_messages(messages, instance_name)}"
|
|
2257
|
-
if welcome_prefix:
|
|
2258
|
-
reason = f"{welcome_prefix}{reason}"
|
|
2259
|
-
output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
|
|
2260
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
2261
|
-
sys.exit(EXIT_BLOCK)
|
|
2262
|
-
elif messages:
|
|
2263
|
-
# Just received
|
|
3001
|
+
messages = messages[:get_config_value('max_messages_per_delivery', 50)]
|
|
2264
3002
|
reason = format_hook_messages(messages, instance_name)
|
|
2265
|
-
if
|
|
2266
|
-
reason = f"{welcome_prefix}{reason}"
|
|
2267
|
-
output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
|
|
2268
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
2269
|
-
sys.exit(EXIT_BLOCK)
|
|
3003
|
+
response_reason = f"{sent_reason} | {reason}" if sent_reason else reason
|
|
2270
3004
|
elif sent_reason:
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
3005
|
+
response_reason = sent_reason
|
|
3006
|
+
elif sent_reason:
|
|
3007
|
+
# Tools still pending - acknowledge HCOM_SEND without disrupting tool batching
|
|
3008
|
+
response_reason = sent_reason
|
|
3009
|
+
|
|
3010
|
+
# Emit response with formatting if we have anything to say
|
|
3011
|
+
if response_reason:
|
|
3012
|
+
response_reason += HCOM_FORMAT_INSTRUCTIONS
|
|
3013
|
+
# CRITICAL: decision=None when tools are pending to prevent API 400 errors
|
|
3014
|
+
decision = compute_decision_for_visibility(transcript_path)
|
|
3015
|
+
emit_hook_response(response_reason, decision=decision)
|
|
3016
|
+
|
|
3017
|
+
def handle_stop(instance_name, updates):
|
|
3018
|
+
"""Handle Stop hook - poll for messages"""
|
|
3019
|
+
updates['last_stop'] = time.time()
|
|
3020
|
+
timeout = get_config_value('wait_timeout', 1800)
|
|
3021
|
+
updates['wait_timeout'] = timeout
|
|
3022
|
+
|
|
3023
|
+
# Try to update position, but continue on Windows file locking errors
|
|
3024
|
+
try:
|
|
3025
|
+
update_instance_position(instance_name, updates)
|
|
3026
|
+
except Exception as e:
|
|
3027
|
+
# Silently handle initial file locking error and continue
|
|
2285
3028
|
pass
|
|
2286
|
-
|
|
2287
|
-
sys.exit(EXIT_SUCCESS)
|
|
2288
3029
|
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
2293
|
-
sys.exit(EXIT_SUCCESS)
|
|
2294
|
-
|
|
3030
|
+
parent_pid = os.getppid()
|
|
3031
|
+
start_time = time.time()
|
|
3032
|
+
|
|
2295
3033
|
try:
|
|
2296
|
-
|
|
2297
|
-
hook_data = json.load(sys.stdin)
|
|
2298
|
-
transcript_path = hook_data.get('transcript_path', '')
|
|
2299
|
-
instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
|
|
2300
|
-
conversation_uuid = get_conversation_uuid(transcript_path)
|
|
2301
|
-
|
|
2302
|
-
# Migrate instance name if needed (from fallback to UUID-based)
|
|
2303
|
-
instance_name = migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path)
|
|
2304
|
-
|
|
2305
|
-
# Initialize instance if needed
|
|
2306
|
-
initialize_instance_in_position_file(instance_name, conversation_uuid)
|
|
2307
|
-
|
|
2308
|
-
# Get the timeout this hook will use
|
|
2309
|
-
timeout = get_config_value('wait_timeout', 1800)
|
|
2310
|
-
|
|
2311
|
-
# Get existing position data to check if PID is already set
|
|
2312
|
-
existing_data = load_instance_position(instance_name)
|
|
2313
|
-
|
|
2314
|
-
# Update instance as waiting and store the timeout
|
|
2315
|
-
updates = {
|
|
2316
|
-
'last_stop': time.time(),
|
|
2317
|
-
'wait_timeout': timeout, # Store this instance's timeout
|
|
2318
|
-
'session_id': hook_data.get('session_id', ''),
|
|
2319
|
-
'transcript_path': transcript_path,
|
|
2320
|
-
'conversation_uuid': conversation_uuid or 'unknown',
|
|
2321
|
-
'directory': str(Path.cwd()),
|
|
2322
|
-
}
|
|
2323
|
-
|
|
2324
|
-
# Update PID if not already set
|
|
2325
|
-
update_instance_with_pid(updates, existing_data)
|
|
2326
|
-
|
|
2327
|
-
# Check if this is a background instance and save log file
|
|
2328
|
-
bg_env = os.environ.get('HCOM_BACKGROUND')
|
|
2329
|
-
if bg_env:
|
|
2330
|
-
updates['background'] = True
|
|
2331
|
-
updates['background_log_file'] = str(get_hcom_dir() / 'logs' / bg_env)
|
|
2332
|
-
|
|
2333
|
-
update_instance_position(instance_name, updates)
|
|
2334
|
-
|
|
2335
|
-
parent_pid = os.getppid()
|
|
2336
|
-
|
|
2337
|
-
# Check for first-use help
|
|
2338
|
-
check_and_show_first_use_help(instance_name)
|
|
2339
|
-
|
|
2340
|
-
# Simple polling loop with parent check
|
|
2341
|
-
start_time = time.time()
|
|
2342
|
-
|
|
3034
|
+
loop_count = 0
|
|
2343
3035
|
while time.time() - start_time < timeout:
|
|
2344
|
-
|
|
2345
|
-
|
|
3036
|
+
loop_count += 1
|
|
3037
|
+
current_time = time.time()
|
|
3038
|
+
|
|
3039
|
+
# Unix/Mac: Check if orphaned (reparented to PID 1)
|
|
3040
|
+
if not IS_WINDOWS and os.getppid() == 1:
|
|
2346
3041
|
sys.exit(EXIT_SUCCESS)
|
|
2347
|
-
|
|
2348
|
-
# Check for new messages
|
|
2349
|
-
messages = get_new_messages(instance_name)
|
|
2350
|
-
|
|
2351
|
-
if messages:
|
|
2352
|
-
# Deliver messages
|
|
2353
|
-
max_messages = get_config_value('max_messages_per_delivery', 50)
|
|
2354
|
-
messages_to_show = messages[:max_messages]
|
|
2355
|
-
|
|
2356
|
-
reason = format_hook_messages(messages_to_show, instance_name)
|
|
2357
|
-
output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
|
|
2358
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
2359
|
-
sys.exit(EXIT_BLOCK)
|
|
2360
|
-
|
|
2361
|
-
# Update heartbeat
|
|
2362
|
-
update_instance_position(instance_name, {
|
|
2363
|
-
'last_stop': time.time()
|
|
2364
|
-
})
|
|
2365
|
-
|
|
2366
|
-
time.sleep(0.1)
|
|
2367
|
-
|
|
2368
|
-
except Exception:
|
|
2369
|
-
pass
|
|
2370
|
-
|
|
2371
|
-
sys.exit(EXIT_SUCCESS)
|
|
2372
3042
|
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
3043
|
+
# All platforms: Check if parent is alive
|
|
3044
|
+
parent_alive = is_parent_alive(parent_pid)
|
|
3045
|
+
|
|
3046
|
+
if not parent_alive:
|
|
3047
|
+
sys.exit(EXIT_SUCCESS)
|
|
3048
|
+
|
|
3049
|
+
# Check for pending tools before delivering messages
|
|
3050
|
+
transcript_path = updates.get('transcript_path', '')
|
|
3051
|
+
pending_count = get_pending_tools(transcript_path)
|
|
3052
|
+
|
|
3053
|
+
# Only deliver messages when no tools are pending
|
|
3054
|
+
if pending_count == 0:
|
|
3055
|
+
messages = get_new_messages(instance_name)
|
|
3056
|
+
if messages:
|
|
3057
|
+
messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
|
|
3058
|
+
reason = format_hook_messages(messages_to_show, instance_name)
|
|
3059
|
+
emit_hook_response(reason) # Normal visible delivery
|
|
3060
|
+
|
|
3061
|
+
# Update position to keep instance marked as alive
|
|
3062
|
+
stop_update_time = time.time()
|
|
3063
|
+
try:
|
|
3064
|
+
update_instance_position(instance_name, {'last_stop': stop_update_time})
|
|
3065
|
+
except Exception as e:
|
|
3066
|
+
# Silently handle file locking exceptions on Windows and continue polling
|
|
3067
|
+
pass
|
|
3068
|
+
|
|
3069
|
+
time.sleep(STOP_HOOK_POLL_INTERVAL)
|
|
3070
|
+
|
|
3071
|
+
except Exception as e:
|
|
3072
|
+
# Exit with code 0 on unexpected exceptions (fail safe)
|
|
2377
3073
|
sys.exit(EXIT_SUCCESS)
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
3074
|
+
|
|
3075
|
+
def handle_notify(hook_data, instance_name, updates):
|
|
3076
|
+
"""Handle Notification hook - track permission requests"""
|
|
3077
|
+
updates['last_permission_request'] = int(time.time())
|
|
3078
|
+
updates['notification_message'] = hook_data.get('message', '')
|
|
3079
|
+
update_instance_position(instance_name, updates)
|
|
3080
|
+
|
|
3081
|
+
def handle_sessionstart(hook_data, instance_name, updates):
|
|
3082
|
+
"""Handle SessionStart hook - deliver welcome/resume message"""
|
|
3083
|
+
source = hook_data.get('source', 'startup')
|
|
3084
|
+
|
|
3085
|
+
# Reset alias_announced flag so alias shows again on resume/clear/compact
|
|
3086
|
+
updates['alias_announced'] = False
|
|
3087
|
+
|
|
3088
|
+
# Always show base help text
|
|
3089
|
+
help_text = "[Welcome! HCOM chat active. Send messages: echo 'HCOM_SEND:your message']"
|
|
3090
|
+
|
|
3091
|
+
# Add subagent type if this is a named agent
|
|
3092
|
+
subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
|
|
3093
|
+
if subagent_type:
|
|
3094
|
+
help_text += f" [Subagent: {subagent_type}]"
|
|
3095
|
+
|
|
3096
|
+
# Add first use text only on startup
|
|
3097
|
+
if source == 'startup':
|
|
3098
|
+
first_use_text = get_config_value('first_use_text', '')
|
|
3099
|
+
if first_use_text:
|
|
3100
|
+
help_text += f" [{first_use_text}]"
|
|
3101
|
+
elif source == 'resume':
|
|
3102
|
+
if not os.environ.get('HCOM_RESUME_SESSION_ID'):
|
|
3103
|
+
# Implicit resume - prompt for alias recovery
|
|
3104
|
+
help_text += f" [⚠️ Resume detected - temp: {instance_name}. If you had a previous HCOM alias, run: echo \"HCOM_RESUME:your_alias\"]"
|
|
3105
|
+
else:
|
|
3106
|
+
help_text += " [Resuming session - you should have the same hcom alias as before]"
|
|
3107
|
+
|
|
3108
|
+
# Add instance hints to all messages
|
|
3109
|
+
instance_hints = get_config_value('instance_hints', '')
|
|
3110
|
+
if instance_hints:
|
|
3111
|
+
help_text += f" [{instance_hints}]"
|
|
3112
|
+
|
|
3113
|
+
# Output as additionalContext using hookSpecificOutput format
|
|
3114
|
+
output = {
|
|
3115
|
+
"hookSpecificOutput": {
|
|
3116
|
+
"hookEventName": "SessionStart",
|
|
3117
|
+
"additionalContext": help_text
|
|
2400
3118
|
}
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
update_instance_with_pid(updates, existing_data)
|
|
2404
|
-
|
|
2405
|
-
# Check if this is a background instance and save log file
|
|
2406
|
-
bg_env = os.environ.get('HCOM_BACKGROUND')
|
|
2407
|
-
if bg_env:
|
|
2408
|
-
updates['background'] = True
|
|
2409
|
-
updates['background_log_file'] = str(get_hcom_dir() / 'logs' / bg_env)
|
|
2410
|
-
|
|
2411
|
-
update_instance_position(instance_name, updates)
|
|
2412
|
-
|
|
2413
|
-
check_and_show_first_use_help(instance_name)
|
|
2414
|
-
|
|
2415
|
-
except Exception:
|
|
2416
|
-
pass
|
|
2417
|
-
|
|
2418
|
-
sys.exit(EXIT_SUCCESS)
|
|
3119
|
+
}
|
|
3120
|
+
print(json.dumps(output))
|
|
2419
3121
|
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
3122
|
+
# Update instance position
|
|
3123
|
+
update_instance_position(instance_name, updates)
|
|
3124
|
+
|
|
3125
|
+
def handle_hook(hook_type):
|
|
3126
|
+
"""Unified hook handler for all HCOM hooks"""
|
|
2423
3127
|
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
2424
3128
|
sys.exit(EXIT_SUCCESS)
|
|
2425
|
-
|
|
3129
|
+
|
|
2426
3130
|
try:
|
|
2427
|
-
# Read hook input
|
|
2428
3131
|
hook_data = json.load(sys.stdin)
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
3132
|
+
|
|
3133
|
+
# Route to specific handler with only needed parameters
|
|
3134
|
+
if hook_type == 'pre':
|
|
3135
|
+
# PreToolUse only needs hook_data
|
|
3136
|
+
handle_pretooluse(hook_data)
|
|
3137
|
+
else:
|
|
3138
|
+
# Other hooks need context initialization
|
|
3139
|
+
instance_name, updates, _ = init_hook_context(hook_data)
|
|
3140
|
+
|
|
3141
|
+
if hook_type == 'post':
|
|
3142
|
+
handle_posttooluse(hook_data, instance_name, updates)
|
|
3143
|
+
elif hook_type == 'stop':
|
|
3144
|
+
# Stop hook doesn't use hook_data
|
|
3145
|
+
handle_stop(instance_name, updates)
|
|
3146
|
+
elif hook_type == 'notify':
|
|
3147
|
+
handle_notify(hook_data, instance_name, updates)
|
|
3148
|
+
elif hook_type == 'sessionstart':
|
|
3149
|
+
handle_sessionstart(hook_data, instance_name, updates)
|
|
3150
|
+
|
|
2449
3151
|
except Exception:
|
|
2450
3152
|
pass
|
|
2451
|
-
|
|
2452
|
-
# Let normal permission flow continue for non-HCOM_SEND commands
|
|
3153
|
+
|
|
2453
3154
|
sys.exit(EXIT_SUCCESS)
|
|
2454
3155
|
|
|
3156
|
+
|
|
2455
3157
|
# ==================== Main Entry Point ====================
|
|
2456
3158
|
|
|
2457
3159
|
def main(argv=None):
|
|
@@ -2484,19 +3186,9 @@ def main(argv=None):
|
|
|
2484
3186
|
return cmd_kill(*argv[2:])
|
|
2485
3187
|
|
|
2486
3188
|
# Hook commands
|
|
2487
|
-
elif cmd
|
|
2488
|
-
|
|
2489
|
-
return 0
|
|
2490
|
-
elif cmd == 'stop':
|
|
2491
|
-
handle_hook_stop()
|
|
3189
|
+
elif cmd in ['post', 'stop', 'notify', 'pre', 'sessionstart']:
|
|
3190
|
+
handle_hook(cmd)
|
|
2492
3191
|
return 0
|
|
2493
|
-
elif cmd == 'notify':
|
|
2494
|
-
handle_hook_notification()
|
|
2495
|
-
return 0
|
|
2496
|
-
elif cmd == 'pre':
|
|
2497
|
-
handle_hook_pretooluse()
|
|
2498
|
-
return 0
|
|
2499
|
-
|
|
2500
3192
|
|
|
2501
3193
|
# Unknown command
|
|
2502
3194
|
else:
|