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