hcom 0.1.8__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 +1941 -912
- hcom-0.2.1.dist-info/METADATA +423 -0
- hcom-0.2.1.dist-info/RECORD +7 -0
- hcom-0.1.8.dist-info/METADATA +0 -331
- hcom-0.1.8.dist-info/RECORD +0 -7
- {hcom-0.1.8.dist-info → hcom-0.2.1.dist-info}/WHEEL +0 -0
- {hcom-0.1.8.dist-info → hcom-0.2.1.dist-info}/entry_points.txt +0 -0
- {hcom-0.1.8.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
|
|
@@ -11,17 +11,29 @@ import tempfile
|
|
|
11
11
|
import shutil
|
|
12
12
|
import shlex
|
|
13
13
|
import re
|
|
14
|
+
import subprocess
|
|
14
15
|
import time
|
|
15
16
|
import select
|
|
16
17
|
import threading
|
|
17
18
|
import platform
|
|
19
|
+
import random
|
|
18
20
|
from pathlib import Path
|
|
19
|
-
from datetime import datetime
|
|
21
|
+
from datetime import datetime, timedelta
|
|
20
22
|
|
|
21
23
|
# ==================== Constants ====================
|
|
22
24
|
|
|
23
25
|
IS_WINDOWS = sys.platform == 'win32'
|
|
24
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
|
+
|
|
25
37
|
HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
|
|
26
38
|
HCOM_ACTIVE_VALUE = '1'
|
|
27
39
|
|
|
@@ -31,16 +43,55 @@ EXIT_BLOCK = 2
|
|
|
31
43
|
|
|
32
44
|
HOOK_DECISION_BLOCK = 'block'
|
|
33
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
|
+
|
|
34
87
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
35
88
|
TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
|
|
36
89
|
|
|
37
90
|
RESET = "\033[0m"
|
|
38
91
|
DIM = "\033[2m"
|
|
39
92
|
BOLD = "\033[1m"
|
|
40
|
-
FG_BLUE = "\033[34m"
|
|
41
93
|
FG_GREEN = "\033[32m"
|
|
42
94
|
FG_CYAN = "\033[36m"
|
|
43
|
-
FG_RED = "\033[31m"
|
|
44
95
|
FG_WHITE = "\033[37m"
|
|
45
96
|
FG_BLACK = "\033[30m"
|
|
46
97
|
BG_BLUE = "\033[44m"
|
|
@@ -58,6 +109,23 @@ STATUS_MAP = {
|
|
|
58
109
|
"inactive": (BG_RED, "○")
|
|
59
110
|
}
|
|
60
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
|
+
|
|
61
129
|
# ==================== Configuration ====================
|
|
62
130
|
|
|
63
131
|
DEFAULT_CONFIG = {
|
|
@@ -67,12 +135,13 @@ DEFAULT_CONFIG = {
|
|
|
67
135
|
"sender_name": "bigboss",
|
|
68
136
|
"sender_emoji": "🐳",
|
|
69
137
|
"cli_hints": "",
|
|
70
|
-
"wait_timeout": 1800,
|
|
71
|
-
"max_message_size":
|
|
138
|
+
"wait_timeout": 1800, # 30mins
|
|
139
|
+
"max_message_size": 1048576, # 1MB
|
|
72
140
|
"max_messages_per_delivery": 50,
|
|
73
141
|
"first_use_text": "Essential, concise messages only, say hi in hcom chat now",
|
|
74
142
|
"instance_hints": "",
|
|
75
|
-
"env_overrides": {}
|
|
143
|
+
"env_overrides": {},
|
|
144
|
+
"auto_watch": True # Auto-launch watch dashboard after open
|
|
76
145
|
}
|
|
77
146
|
|
|
78
147
|
_config = None
|
|
@@ -88,30 +157,170 @@ HOOK_SETTINGS = {
|
|
|
88
157
|
'cli_hints': 'HCOM_CLI_HINTS',
|
|
89
158
|
'terminal_mode': 'HCOM_TERMINAL_MODE',
|
|
90
159
|
'terminal_command': 'HCOM_TERMINAL_COMMAND',
|
|
91
|
-
'initial_prompt': 'HCOM_INITIAL_PROMPT'
|
|
160
|
+
'initial_prompt': 'HCOM_INITIAL_PROMPT',
|
|
161
|
+
'auto_watch': 'HCOM_AUTO_WATCH'
|
|
92
162
|
}
|
|
93
163
|
|
|
94
|
-
#
|
|
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"
|
|
95
170
|
|
|
96
|
-
|
|
97
|
-
"""Get the hcom directory in user's home"""
|
|
98
|
-
return Path.home() / ".hcom"
|
|
171
|
+
# ==================== File System Utilities ====================
|
|
99
172
|
|
|
100
|
-
def
|
|
101
|
-
"""
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
105
181
|
|
|
106
182
|
def atomic_write(filepath, content):
|
|
107
|
-
"""Write content to file atomically to prevent corruption"""
|
|
108
|
-
filepath = Path(filepath)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
234
|
+
|
|
235
|
+
def get_instance_file(instance_name):
|
|
236
|
+
"""Get path to instance's position file"""
|
|
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
|
|
257
|
+
|
|
258
|
+
def load_instance_position(instance_name):
|
|
259
|
+
"""Load position data for a single instance"""
|
|
260
|
+
instance_file = get_instance_file(instance_name)
|
|
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
|
|
273
|
+
|
|
274
|
+
def save_instance_position(instance_name, data):
|
|
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
|
|
281
|
+
|
|
282
|
+
def load_all_positions():
|
|
283
|
+
"""Load positions from all instance files"""
|
|
284
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
285
|
+
if not instances_dir.exists():
|
|
286
|
+
return {}
|
|
287
|
+
|
|
288
|
+
positions = {}
|
|
289
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
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
|
|
298
|
+
return positions
|
|
299
|
+
|
|
300
|
+
def clear_all_positions():
|
|
301
|
+
"""Clear all instance position files"""
|
|
302
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
303
|
+
if instances_dir.exists():
|
|
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
|
+
|
|
115
324
|
|
|
116
325
|
# ==================== Configuration System ====================
|
|
117
326
|
|
|
@@ -124,28 +333,28 @@ def get_cached_config():
|
|
|
124
333
|
|
|
125
334
|
def _load_config_from_file():
|
|
126
335
|
"""Actually load configuration from ~/.hcom/config.json"""
|
|
127
|
-
|
|
128
|
-
config_path =
|
|
129
|
-
|
|
130
|
-
config =
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
|
|
149
358
|
return config
|
|
150
359
|
|
|
151
360
|
def get_config_value(key, default=None):
|
|
@@ -163,9 +372,11 @@ def get_config_value(key, default=None):
|
|
|
163
372
|
return int(env_value)
|
|
164
373
|
except ValueError:
|
|
165
374
|
pass
|
|
375
|
+
elif key == 'auto_watch': # Convert string to boolean
|
|
376
|
+
return env_value.lower() in ('true', '1', 'yes', 'on')
|
|
166
377
|
else:
|
|
167
378
|
return env_value
|
|
168
|
-
|
|
379
|
+
|
|
169
380
|
config = get_cached_config()
|
|
170
381
|
return config.get(key, default)
|
|
171
382
|
|
|
@@ -178,27 +389,37 @@ def get_hook_command():
|
|
|
178
389
|
python_path = sys.executable
|
|
179
390
|
script_path = os.path.abspath(__file__)
|
|
180
391
|
|
|
181
|
-
if
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
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)
|
|
185
401
|
return f'[ "${{HCOM_ACTIVE}}" = "1" ] && {escaped_python} {escaped_script} || true', {}
|
|
186
402
|
else:
|
|
187
|
-
#
|
|
403
|
+
# Unix clean paths: use environment variable
|
|
188
404
|
return '${HCOM:-true}', {}
|
|
189
405
|
|
|
190
406
|
def build_claude_env():
|
|
191
407
|
"""Build environment variables for Claude instances"""
|
|
192
408
|
env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
|
|
193
409
|
|
|
410
|
+
# Get config file values
|
|
194
411
|
config = get_cached_config()
|
|
412
|
+
|
|
413
|
+
# Pass env vars only when they differ from config file values
|
|
195
414
|
for config_key, env_var in HOOK_SETTINGS.items():
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
415
|
+
actual_value = get_config_value(config_key) # Respects env var precedence
|
|
416
|
+
config_file_value = config.get(config_key)
|
|
417
|
+
|
|
418
|
+
# Only pass if different from config file (not default)
|
|
419
|
+
if actual_value != config_file_value and actual_value is not None:
|
|
420
|
+
env[env_var] = str(actual_value)
|
|
201
421
|
|
|
422
|
+
# Still support env_overrides from config file
|
|
202
423
|
env.update(config.get('env_overrides', {}))
|
|
203
424
|
|
|
204
425
|
# Set HCOM only for clean paths (spaces handled differently)
|
|
@@ -215,38 +436,21 @@ def validate_message(message):
|
|
|
215
436
|
"""Validate message size and content"""
|
|
216
437
|
if not message or not message.strip():
|
|
217
438
|
return format_error("Message required")
|
|
218
|
-
|
|
219
|
-
|
|
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)
|
|
220
445
|
if len(message) > max_size:
|
|
221
446
|
return format_error(f"Message too large (max {max_size} chars)")
|
|
222
|
-
|
|
223
|
-
return None
|
|
224
447
|
|
|
225
|
-
|
|
226
|
-
"""Check argument count and exit with usage if insufficient"""
|
|
227
|
-
if len(sys.argv) < min_count:
|
|
228
|
-
print(f"Usage: {usage_msg}")
|
|
229
|
-
if extra_msg:
|
|
230
|
-
print(extra_msg)
|
|
231
|
-
sys.exit(1)
|
|
232
|
-
|
|
233
|
-
def load_positions(pos_file):
|
|
234
|
-
"""Load positions from file with error handling"""
|
|
235
|
-
positions = {}
|
|
236
|
-
if pos_file.exists():
|
|
237
|
-
try:
|
|
238
|
-
with open(pos_file, 'r') as f:
|
|
239
|
-
positions = json.load(f)
|
|
240
|
-
except (json.JSONDecodeError, FileNotFoundError):
|
|
241
|
-
pass
|
|
242
|
-
return positions
|
|
448
|
+
return None
|
|
243
449
|
|
|
244
450
|
def send_message(from_instance, message):
|
|
245
451
|
"""Send a message to the log"""
|
|
246
452
|
try:
|
|
247
|
-
|
|
248
|
-
log_file = get_hcom_dir() / "hcom.log"
|
|
249
|
-
pos_file = get_hcom_dir() / "hcom.json"
|
|
453
|
+
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
250
454
|
|
|
251
455
|
escaped_message = message.replace('|', '\\|')
|
|
252
456
|
escaped_from = from_instance.replace('|', '\\|')
|
|
@@ -254,7 +458,7 @@ def send_message(from_instance, message):
|
|
|
254
458
|
timestamp = datetime.now().isoformat()
|
|
255
459
|
line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
|
|
256
460
|
|
|
257
|
-
with open(log_file, 'a') as f:
|
|
461
|
+
with open(log_file, 'a', encoding='utf-8') as f:
|
|
258
462
|
f.write(line)
|
|
259
463
|
f.flush()
|
|
260
464
|
|
|
@@ -280,12 +484,17 @@ def should_deliver_message(msg, instance_name, all_instance_names=None):
|
|
|
280
484
|
if this_instance_matches:
|
|
281
485
|
return True
|
|
282
486
|
|
|
283
|
-
#
|
|
487
|
+
# Check if any mention is for the CLI sender (bigboss)
|
|
488
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
489
|
+
sender_mentioned = any(sender_name.lower().startswith(mention.lower()) for mention in mentions)
|
|
490
|
+
|
|
491
|
+
# If we have all_instance_names, check if ANY mention matches ANY instance or sender
|
|
284
492
|
if all_instance_names:
|
|
285
493
|
any_mention_matches = any(
|
|
286
494
|
any(name.lower().startswith(mention.lower()) for name in all_instance_names)
|
|
287
495
|
for mention in mentions
|
|
288
|
-
)
|
|
496
|
+
) or sender_mentioned
|
|
497
|
+
|
|
289
498
|
if not any_mention_matches:
|
|
290
499
|
return True # No matches anywhere = broadcast to all
|
|
291
500
|
|
|
@@ -297,14 +506,16 @@ def parse_open_args(args):
|
|
|
297
506
|
"""Parse arguments for open command
|
|
298
507
|
|
|
299
508
|
Returns:
|
|
300
|
-
tuple: (instances, prefix, claude_args)
|
|
509
|
+
tuple: (instances, prefix, claude_args, background)
|
|
301
510
|
instances: list of agent names or 'generic'
|
|
302
511
|
prefix: team name prefix or None
|
|
303
512
|
claude_args: additional args to pass to claude
|
|
513
|
+
background: bool, True if --background or -p flag
|
|
304
514
|
"""
|
|
305
515
|
instances = []
|
|
306
516
|
prefix = None
|
|
307
517
|
claude_args = []
|
|
518
|
+
background = False
|
|
308
519
|
|
|
309
520
|
i = 0
|
|
310
521
|
while i < len(args):
|
|
@@ -323,6 +534,9 @@ def parse_open_args(args):
|
|
|
323
534
|
raise ValueError(format_error('--claude-args requires an argument'))
|
|
324
535
|
claude_args = shlex.split(args[i + 1])
|
|
325
536
|
i += 2
|
|
537
|
+
elif arg == '--background' or arg == '-p':
|
|
538
|
+
background = True
|
|
539
|
+
i += 1
|
|
326
540
|
else:
|
|
327
541
|
try:
|
|
328
542
|
count = int(arg)
|
|
@@ -341,7 +555,7 @@ def parse_open_args(args):
|
|
|
341
555
|
if not instances:
|
|
342
556
|
instances = ['generic']
|
|
343
557
|
|
|
344
|
-
return instances, prefix, claude_args
|
|
558
|
+
return instances, prefix, claude_args, background
|
|
345
559
|
|
|
346
560
|
def extract_agent_config(content):
|
|
347
561
|
"""Extract configuration from agent YAML frontmatter"""
|
|
@@ -381,10 +595,16 @@ def resolve_agent(name):
|
|
|
381
595
|
|
|
382
596
|
Returns tuple: (content after stripping YAML frontmatter, config dict)
|
|
383
597
|
"""
|
|
384
|
-
for base_path in [Path(
|
|
598
|
+
for base_path in [Path.cwd(), Path.home()]:
|
|
385
599
|
agent_path = base_path / '.claude/agents' / f'{name}.md'
|
|
386
600
|
if agent_path.exists():
|
|
387
|
-
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
|
|
388
608
|
config = extract_agent_config(content)
|
|
389
609
|
stripped = strip_frontmatter(content)
|
|
390
610
|
if not stripped.strip():
|
|
@@ -397,37 +617,66 @@ def strip_frontmatter(content):
|
|
|
397
617
|
"""Strip YAML frontmatter from agent file"""
|
|
398
618
|
if content.startswith('---'):
|
|
399
619
|
# Find the closing --- on its own line
|
|
400
|
-
lines = content.
|
|
620
|
+
lines = content.splitlines()
|
|
401
621
|
for i, line in enumerate(lines[1:], 1):
|
|
402
622
|
if line.strip() == '---':
|
|
403
623
|
return '\n'.join(lines[i+1:]).strip()
|
|
404
624
|
return content
|
|
405
625
|
|
|
406
|
-
def get_display_name(
|
|
407
|
-
"""Get display name for instance"""
|
|
626
|
+
def get_display_name(session_id, prefix=None):
|
|
627
|
+
"""Get display name for instance using session_id"""
|
|
408
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']
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
418
662
|
else:
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
|
|
421
667
|
if prefix:
|
|
422
668
|
return f"{prefix}-{base_name}"
|
|
423
669
|
return base_name
|
|
424
670
|
|
|
425
671
|
def _remove_hcom_hooks_from_settings(settings):
|
|
426
672
|
"""Remove hcom hooks from settings dict"""
|
|
427
|
-
if 'hooks' not in settings:
|
|
673
|
+
if not isinstance(settings, dict) or 'hooks' not in settings:
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
if not isinstance(settings['hooks'], dict):
|
|
428
677
|
return
|
|
429
678
|
|
|
430
|
-
import
|
|
679
|
+
import copy
|
|
431
680
|
|
|
432
681
|
# Patterns to match any hcom hook command
|
|
433
682
|
# - $HCOM post/stop/notify
|
|
@@ -439,53 +688,67 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
439
688
|
# - sh -c "[ ... ] && ... hcom ..."
|
|
440
689
|
# - "/path with spaces/python" "/path with spaces/hcom.py" post/stop/notify
|
|
441
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.
|
|
442
695
|
hcom_patterns = [
|
|
443
|
-
r'\$\{?HCOM', # Environment variable (
|
|
444
|
-
r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with
|
|
445
|
-
r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command
|
|
446
|
-
r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command
|
|
447
|
-
r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote
|
|
448
|
-
r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b', # 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)
|
|
449
702
|
r'sh\s+-c.*hcom', # Shell wrapper with hcom
|
|
450
703
|
]
|
|
451
704
|
compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
|
|
452
705
|
|
|
453
|
-
for event in ['PostToolUse', 'Stop', 'Notification']:
|
|
706
|
+
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
|
|
454
707
|
if event not in settings['hooks']:
|
|
455
708
|
continue
|
|
456
709
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
710
|
+
# Process each matcher
|
|
711
|
+
updated_matchers = []
|
|
712
|
+
for matcher in settings['hooks'][event]:
|
|
713
|
+
# Fail fast on malformed settings - Claude won't run with broken settings anyway
|
|
714
|
+
if not isinstance(matcher, dict):
|
|
715
|
+
raise ValueError(f"Malformed settings: matcher in {event} is not a dict: {type(matcher).__name__}")
|
|
716
|
+
|
|
717
|
+
# Work with a copy to avoid any potential reference issues
|
|
718
|
+
matcher_copy = copy.deepcopy(matcher)
|
|
719
|
+
|
|
720
|
+
# Filter out HCOM hooks from this matcher
|
|
721
|
+
non_hcom_hooks = [
|
|
722
|
+
hook for hook in matcher_copy.get('hooks', [])
|
|
723
|
+
if not any(
|
|
461
724
|
pattern.search(hook.get('command', ''))
|
|
462
725
|
for pattern in compiled_patterns
|
|
463
726
|
)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
727
|
+
]
|
|
728
|
+
|
|
729
|
+
# Only keep the matcher if it has non-HCOM hooks remaining
|
|
730
|
+
if non_hcom_hooks:
|
|
731
|
+
matcher_copy['hooks'] = non_hcom_hooks
|
|
732
|
+
updated_matchers.append(matcher_copy)
|
|
733
|
+
elif not matcher.get('hooks'): # Preserve matchers that never had hooks
|
|
734
|
+
updated_matchers.append(matcher_copy)
|
|
735
|
+
|
|
736
|
+
# Update or remove the event
|
|
737
|
+
if updated_matchers:
|
|
738
|
+
settings['hooks'][event] = updated_matchers
|
|
739
|
+
else:
|
|
469
740
|
del settings['hooks'][event]
|
|
470
741
|
|
|
471
|
-
if not settings['hooks']:
|
|
472
|
-
del settings['hooks']
|
|
473
742
|
|
|
474
743
|
def build_env_string(env_vars, format_type="bash"):
|
|
475
|
-
"""Build environment variable string for
|
|
744
|
+
"""Build environment variable string for bash shells"""
|
|
476
745
|
if format_type == "bash_export":
|
|
477
746
|
# Properly escape values for bash
|
|
478
747
|
return ' '.join(f'export {k}={shlex.quote(str(v))};' for k, v in env_vars.items())
|
|
479
|
-
elif format_type == "powershell":
|
|
480
|
-
# PowerShell environment variable syntax
|
|
481
|
-
items = []
|
|
482
|
-
for k, v in env_vars.items():
|
|
483
|
-
escaped_value = str(v).replace('"', '`"')
|
|
484
|
-
items.append(f'$env:{k}="{escaped_value}"')
|
|
485
|
-
return ' ; '.join(items)
|
|
486
748
|
else:
|
|
487
749
|
return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
|
|
488
750
|
|
|
751
|
+
|
|
489
752
|
def format_error(message, suggestion=None):
|
|
490
753
|
"""Format error message consistently"""
|
|
491
754
|
base = f"Error: {message}"
|
|
@@ -493,38 +756,32 @@ def format_error(message, suggestion=None):
|
|
|
493
756
|
base += f". {suggestion}"
|
|
494
757
|
return base
|
|
495
758
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
+
)
|
|
499
766
|
|
|
500
767
|
def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat", model=None, tools=None):
|
|
501
768
|
"""Build Claude command with proper argument handling
|
|
502
|
-
|
|
769
|
+
|
|
503
770
|
Returns tuple: (command_string, temp_file_path_or_none)
|
|
504
771
|
For agent content, writes to temp file and uses cat to read it.
|
|
505
772
|
"""
|
|
506
773
|
cmd_parts = ['claude']
|
|
507
774
|
temp_file_path = None
|
|
508
|
-
|
|
775
|
+
|
|
509
776
|
# Add model if specified and not already in claude_args
|
|
510
777
|
if model:
|
|
511
|
-
|
|
512
|
-
has_model = claude_args and any(
|
|
513
|
-
arg in ['--model', '-m'] or
|
|
514
|
-
arg.startswith(('--model=', '-m='))
|
|
515
|
-
for arg in claude_args
|
|
516
|
-
)
|
|
517
|
-
if not has_model:
|
|
778
|
+
if not has_claude_arg(claude_args, ['--model', '-m'], ('--model=', '-m=')):
|
|
518
779
|
cmd_parts.extend(['--model', model])
|
|
519
|
-
|
|
780
|
+
|
|
520
781
|
# Add allowed tools if specified and not already in claude_args
|
|
521
782
|
if tools:
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
arg.startswith(('--allowedTools=', '--allowed-tools='))
|
|
525
|
-
for arg in claude_args
|
|
526
|
-
)
|
|
527
|
-
if not has_tools:
|
|
783
|
+
if not has_claude_arg(claude_args, ['--allowedTools', '--allowed-tools'],
|
|
784
|
+
('--allowedTools=', '--allowed-tools=')):
|
|
528
785
|
cmd_parts.extend(['--allowedTools', tools])
|
|
529
786
|
|
|
530
787
|
if claude_args:
|
|
@@ -532,7 +789,7 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
532
789
|
cmd_parts.append(shlex.quote(arg))
|
|
533
790
|
|
|
534
791
|
if agent_content:
|
|
535
|
-
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False,
|
|
792
|
+
temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
|
|
536
793
|
prefix='hcom_agent_', dir=tempfile.gettempdir())
|
|
537
794
|
temp_file.write(agent_content)
|
|
538
795
|
temp_file.close()
|
|
@@ -544,18 +801,13 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
544
801
|
flag = '--append-system-prompt'
|
|
545
802
|
|
|
546
803
|
cmd_parts.append(flag)
|
|
547
|
-
|
|
548
|
-
# PowerShell handles paths differently, quote with single quotes
|
|
549
|
-
escaped_path = temp_file_path.replace("'", "''")
|
|
550
|
-
cmd_parts.append(f"\"$(Get-Content '{escaped_path}' -Raw)\"")
|
|
551
|
-
else:
|
|
552
|
-
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
804
|
+
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
553
805
|
|
|
554
806
|
if claude_args or agent_content:
|
|
555
807
|
cmd_parts.append('--')
|
|
556
808
|
|
|
557
809
|
# Quote initial prompt normally
|
|
558
|
-
cmd_parts.append(
|
|
810
|
+
cmd_parts.append(shell_quote(initial_prompt))
|
|
559
811
|
|
|
560
812
|
return ' '.join(cmd_parts), temp_file_path
|
|
561
813
|
|
|
@@ -569,78 +821,286 @@ def escape_for_platform(text, platform_type):
|
|
|
569
821
|
.replace('\n', '\\n') # Escape newlines
|
|
570
822
|
.replace('\r', '\\r') # Escape carriage returns
|
|
571
823
|
.replace('\t', '\\t')) # Escape tabs
|
|
572
|
-
elif platform_type == 'powershell':
|
|
573
|
-
# PowerShell escaping - use backticks for special chars
|
|
574
|
-
return text.replace('`', '``').replace('"', '`"').replace('$', '`$')
|
|
575
824
|
else: # POSIX/bash
|
|
576
825
|
return shlex.quote(text)
|
|
577
826
|
|
|
578
|
-
def
|
|
579
|
-
"""
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
592
910
|
|
|
593
|
-
def
|
|
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()
|
|
926
|
+
|
|
927
|
+
def launch_terminal(command, env, config=None, cwd=None, background=False):
|
|
594
928
|
"""Launch terminal with command
|
|
595
|
-
|
|
596
929
|
Args:
|
|
597
930
|
command: Either a string command or list of command parts
|
|
598
931
|
env: Environment variables to set
|
|
599
932
|
config: Configuration dict
|
|
600
933
|
cwd: Working directory
|
|
934
|
+
background: Launch as background process
|
|
601
935
|
"""
|
|
602
|
-
|
|
603
|
-
|
|
936
|
+
|
|
604
937
|
if config is None:
|
|
605
938
|
config = get_cached_config()
|
|
606
|
-
|
|
939
|
+
|
|
607
940
|
env_vars = os.environ.copy()
|
|
608
941
|
env_vars.update(env)
|
|
609
|
-
|
|
610
|
-
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
611
|
-
|
|
942
|
+
|
|
612
943
|
# Command should now always be a string from build_claude_command
|
|
613
944
|
command_str = command
|
|
614
945
|
|
|
946
|
+
# Background mode implementation
|
|
947
|
+
if background:
|
|
948
|
+
# Create log file for background instance
|
|
949
|
+
logs_dir = hcom_path(LOGS_DIR)
|
|
950
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
951
|
+
log_file = logs_dir / env['HCOM_BACKGROUND']
|
|
952
|
+
|
|
953
|
+
# Launch detached process
|
|
954
|
+
try:
|
|
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
|
+
)
|
|
997
|
+
except OSError as e:
|
|
998
|
+
print(format_error(f"Failed to launch background instance: {e}"), file=sys.stderr)
|
|
999
|
+
return None
|
|
1000
|
+
|
|
1001
|
+
# Check for immediate failures
|
|
1002
|
+
time.sleep(0.2)
|
|
1003
|
+
if process.poll() is not None:
|
|
1004
|
+
# Process already exited
|
|
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)
|
|
1011
|
+
if error_output:
|
|
1012
|
+
print(f" Output: {error_output}", file=sys.stderr)
|
|
1013
|
+
return None
|
|
1014
|
+
|
|
1015
|
+
return str(log_file)
|
|
1016
|
+
|
|
1017
|
+
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
1018
|
+
|
|
615
1019
|
if terminal_mode == 'show_commands':
|
|
616
1020
|
env_str = build_env_string(env)
|
|
617
1021
|
print(f"{env_str} {command_str}")
|
|
618
1022
|
return True
|
|
619
|
-
|
|
1023
|
+
|
|
620
1024
|
elif terminal_mode == 'same_terminal':
|
|
621
1025
|
print(f"Launching Claude in current terminal...")
|
|
622
|
-
|
|
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
|
+
|
|
623
1038
|
return result.returncode == 0
|
|
624
1039
|
|
|
625
1040
|
system = platform.system()
|
|
626
|
-
|
|
1041
|
+
|
|
627
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
|
+
|
|
628
1067
|
if custom_cmd and custom_cmd != 'None' and custom_cmd != 'null':
|
|
629
|
-
#
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
644
1104
|
|
|
645
1105
|
if system == 'Darwin': # macOS
|
|
646
1106
|
env_setup = build_env_string(env, "bash_export")
|
|
@@ -658,12 +1118,13 @@ def launch_terminal(command, env, config=None, cwd=None):
|
|
|
658
1118
|
return True
|
|
659
1119
|
|
|
660
1120
|
elif system == 'Linux':
|
|
1121
|
+
# Try Linux terminals first (works for both regular Linux and WSLg)
|
|
661
1122
|
terminals = [
|
|
662
1123
|
('gnome-terminal', ['gnome-terminal', '--', 'bash', '-c']),
|
|
663
1124
|
('konsole', ['konsole', '-e', 'bash', '-c']),
|
|
664
1125
|
('xterm', ['xterm', '-e', 'bash', '-c'])
|
|
665
1126
|
]
|
|
666
|
-
|
|
1127
|
+
|
|
667
1128
|
for term_name, term_cmd in terminals:
|
|
668
1129
|
if shutil.which(term_name):
|
|
669
1130
|
env_cmd = build_env_string(env)
|
|
@@ -674,27 +1135,30 @@ def launch_terminal(command, env, config=None, cwd=None):
|
|
|
674
1135
|
full_cmd = f'{env_cmd} {command_str}; exec bash'
|
|
675
1136
|
subprocess.run(term_cmd + [full_cmd])
|
|
676
1137
|
return True
|
|
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
|
+
|
|
1158
|
+
raise Exception(format_error("No supported terminal emulator found", "Install gnome-terminal, konsole, or xterm"))
|
|
677
1159
|
|
|
678
|
-
raise Exception(format_error("No supported terminal emulator found", "Install gnome-terminal, konsole, xfce4-terminal, or xterm"))
|
|
679
|
-
|
|
680
|
-
elif system == 'Windows':
|
|
681
|
-
# Windows Terminal with PowerShell
|
|
682
|
-
env_setup = build_env_string(env, "powershell")
|
|
683
|
-
# Include cd command if cwd is specified
|
|
684
|
-
if cwd:
|
|
685
|
-
full_cmd = f'cd "{cwd}" ; {env_setup} ; {command_str}'
|
|
686
|
-
else:
|
|
687
|
-
full_cmd = f'{env_setup} ; {command_str}'
|
|
688
|
-
|
|
689
|
-
try:
|
|
690
|
-
# Try Windows Terminal with PowerShell
|
|
691
|
-
subprocess.run(['wt', 'powershell', '-NoExit', '-Command', full_cmd])
|
|
692
|
-
except FileNotFoundError:
|
|
693
|
-
# Fallback to PowerShell directly
|
|
694
|
-
subprocess.run(['powershell', '-NoExit', '-Command', full_cmd])
|
|
695
|
-
return True
|
|
696
|
-
|
|
697
1160
|
else:
|
|
1161
|
+
# Windows is now handled by the custom_cmd logic above
|
|
698
1162
|
raise Exception(format_error(f"Unsupported platform: {system}", "Supported platforms: macOS, Linux, Windows"))
|
|
699
1163
|
|
|
700
1164
|
def setup_hooks():
|
|
@@ -703,166 +1167,188 @@ def setup_hooks():
|
|
|
703
1167
|
claude_dir.mkdir(exist_ok=True)
|
|
704
1168
|
|
|
705
1169
|
settings_path = claude_dir / 'settings.local.json'
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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}"))
|
|
714
1178
|
|
|
715
1179
|
if 'hooks' not in settings:
|
|
716
1180
|
settings['hooks'] = {}
|
|
717
|
-
|
|
718
|
-
settings['permissions'] = {}
|
|
719
|
-
if 'allow' not in settings['permissions']:
|
|
720
|
-
settings['permissions']['allow'] = []
|
|
721
|
-
|
|
1181
|
+
|
|
722
1182
|
_remove_hcom_hooks_from_settings(settings)
|
|
723
|
-
|
|
724
|
-
if 'hooks' not in settings:
|
|
725
|
-
settings['hooks'] = {}
|
|
726
|
-
|
|
727
|
-
hcom_send_permission = 'Bash(echo HCOM_SEND:*)'
|
|
728
|
-
if hcom_send_permission not in settings['permissions']['allow']:
|
|
729
|
-
settings['permissions']['allow'].append(hcom_send_permission)
|
|
730
|
-
|
|
1183
|
+
|
|
731
1184
|
# Get the hook command template
|
|
732
1185
|
hook_cmd_base, _ = get_hook_command()
|
|
733
1186
|
|
|
734
|
-
#
|
|
735
|
-
if 'PostToolUse' not in settings['hooks']:
|
|
736
|
-
settings['hooks']['PostToolUse'] = []
|
|
737
|
-
|
|
738
|
-
settings['hooks']['PostToolUse'].append({
|
|
739
|
-
'matcher': '.*',
|
|
740
|
-
'hooks': [{
|
|
741
|
-
'type': 'command',
|
|
742
|
-
'command': f'{hook_cmd_base} post'
|
|
743
|
-
}]
|
|
744
|
-
})
|
|
745
|
-
|
|
746
|
-
# Add Stop hook
|
|
747
|
-
if 'Stop' not in settings['hooks']:
|
|
748
|
-
settings['hooks']['Stop'] = []
|
|
749
|
-
|
|
1187
|
+
# Get wait_timeout (needed for Stop hook)
|
|
750
1188
|
wait_timeout = get_config_value('wait_timeout', 1800)
|
|
751
1189
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
'
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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)
|
|
772
1214
|
|
|
773
1215
|
# Write settings atomically
|
|
774
|
-
|
|
1216
|
+
try:
|
|
1217
|
+
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
1218
|
+
except Exception as e:
|
|
1219
|
+
raise Exception(format_error(f"Cannot write settings: {e}"))
|
|
1220
|
+
|
|
1221
|
+
# Quick verification
|
|
1222
|
+
if not verify_hooks_installed(settings_path):
|
|
1223
|
+
raise Exception(format_error("Hook installation failed"))
|
|
775
1224
|
|
|
776
1225
|
return True
|
|
777
1226
|
|
|
1227
|
+
def verify_hooks_installed(settings_path):
|
|
1228
|
+
"""Verify that HCOM hooks were installed correctly"""
|
|
1229
|
+
try:
|
|
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
|
|
1239
|
+
hooks = settings.get('hooks', {})
|
|
1240
|
+
for hook_type in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
|
|
1241
|
+
if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
|
|
1242
|
+
for h in hooks.get(hook_type, [])):
|
|
1243
|
+
return False
|
|
1244
|
+
|
|
1245
|
+
return True
|
|
1246
|
+
except Exception:
|
|
1247
|
+
return False
|
|
1248
|
+
|
|
778
1249
|
def is_interactive():
|
|
779
1250
|
"""Check if running in interactive mode"""
|
|
780
1251
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
781
1252
|
|
|
782
1253
|
def get_archive_timestamp():
|
|
783
1254
|
"""Get timestamp for archive files"""
|
|
784
|
-
return datetime.now().strftime("%Y
|
|
785
|
-
|
|
786
|
-
def get_conversation_uuid(transcript_path):
|
|
787
|
-
"""Get conversation UUID from transcript
|
|
788
|
-
|
|
789
|
-
For resumed sessions, the first line may be a summary with a different leafUuid.
|
|
790
|
-
We need to find the first user entry which contains the stable conversation UUID.
|
|
791
|
-
"""
|
|
792
|
-
try:
|
|
793
|
-
if not transcript_path or not os.path.exists(transcript_path):
|
|
794
|
-
return None
|
|
795
|
-
|
|
796
|
-
# First, try to find the UUID from the first user entry
|
|
797
|
-
with open(transcript_path, 'r') as f:
|
|
798
|
-
for line in f:
|
|
799
|
-
line = line.strip()
|
|
800
|
-
if not line:
|
|
801
|
-
continue
|
|
802
|
-
try:
|
|
803
|
-
entry = json.loads(line)
|
|
804
|
-
# Look for first user entry with a UUID - this is the stable identifier
|
|
805
|
-
if entry.get('type') == 'user' and entry.get('uuid'):
|
|
806
|
-
return entry.get('uuid')
|
|
807
|
-
except json.JSONDecodeError:
|
|
808
|
-
continue
|
|
809
|
-
|
|
810
|
-
# Fallback: If no user entry found, try the first line (original behavior)
|
|
811
|
-
with open(transcript_path, 'r') as f:
|
|
812
|
-
first_line = f.readline().strip()
|
|
813
|
-
if first_line:
|
|
814
|
-
entry = json.loads(first_line)
|
|
815
|
-
# Try both 'uuid' and 'leafUuid' fields
|
|
816
|
-
return entry.get('uuid') or entry.get('leafUuid')
|
|
817
|
-
except Exception:
|
|
818
|
-
pass
|
|
819
|
-
return None
|
|
1255
|
+
return datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
820
1256
|
|
|
821
1257
|
def is_parent_alive(parent_pid=None):
|
|
822
1258
|
"""Check if parent process is alive"""
|
|
823
1259
|
if parent_pid is None:
|
|
824
1260
|
parent_pid = os.getppid()
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
+
|
|
1279
|
+
if IS_WINDOWS:
|
|
1280
|
+
# Windows: Use Windows API to check process existence
|
|
1281
|
+
try:
|
|
1282
|
+
kernel32 = get_windows_kernel32() # Use cached kernel32 instance
|
|
1283
|
+
if not kernel32:
|
|
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
|
+
|
|
1311
|
+
kernel32.CloseHandle(handle)
|
|
1312
|
+
return False # Couldn't get exit code
|
|
1313
|
+
except Exception as e:
|
|
1314
|
+
return False
|
|
837
1315
|
else:
|
|
1316
|
+
# Unix: Use os.kill with signal 0
|
|
838
1317
|
try:
|
|
839
|
-
os.kill(
|
|
1318
|
+
os.kill(pid, 0)
|
|
840
1319
|
return True
|
|
841
|
-
except ProcessLookupError:
|
|
1320
|
+
except ProcessLookupError as e:
|
|
1321
|
+
return False
|
|
1322
|
+
except Exception as e:
|
|
842
1323
|
return False
|
|
843
|
-
except Exception:
|
|
844
|
-
return True
|
|
845
1324
|
|
|
846
|
-
def parse_log_messages(log_file, start_pos=0):
|
|
847
|
-
"""Parse messages from log file
|
|
848
|
-
|
|
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
|
+
"""
|
|
849
1334
|
if not log_file.exists():
|
|
850
|
-
return []
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
with open(log_file, 'r') as f:
|
|
1335
|
+
return ([], start_pos) if return_end_pos else []
|
|
1336
|
+
|
|
1337
|
+
def read_messages(f):
|
|
854
1338
|
f.seek(start_pos)
|
|
855
1339
|
content = f.read()
|
|
856
|
-
|
|
1340
|
+
end_pos = f.tell() # Capture actual end position
|
|
1341
|
+
|
|
857
1342
|
if not content.strip():
|
|
858
|
-
return []
|
|
859
|
-
|
|
1343
|
+
return ([], end_pos)
|
|
1344
|
+
|
|
1345
|
+
messages = []
|
|
860
1346
|
message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
|
|
861
|
-
|
|
1347
|
+
|
|
862
1348
|
for entry in message_entries:
|
|
863
1349
|
if not entry or '|' not in entry:
|
|
864
1350
|
continue
|
|
865
|
-
|
|
1351
|
+
|
|
866
1352
|
parts = entry.split('|', 2)
|
|
867
1353
|
if len(parts) == 3:
|
|
868
1354
|
timestamp, from_instance, message = parts
|
|
@@ -871,28 +1357,35 @@ def parse_log_messages(log_file, start_pos=0):
|
|
|
871
1357
|
'from': from_instance.replace('\\|', '|'),
|
|
872
1358
|
'message': message.replace('\\|', '|')
|
|
873
1359
|
})
|
|
874
|
-
|
|
875
|
-
|
|
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]
|
|
876
1370
|
|
|
877
1371
|
def get_new_messages(instance_name):
|
|
878
1372
|
"""Get new messages for instance with @-mention filtering"""
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
pos_file = get_hcom_dir() / "hcom.json"
|
|
882
|
-
|
|
1373
|
+
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
1374
|
+
|
|
883
1375
|
if not log_file.exists():
|
|
884
1376
|
return []
|
|
885
|
-
|
|
886
|
-
positions =
|
|
887
|
-
|
|
1377
|
+
|
|
1378
|
+
positions = load_all_positions()
|
|
1379
|
+
|
|
888
1380
|
# Get last position for this instance
|
|
889
1381
|
last_pos = 0
|
|
890
1382
|
if instance_name in positions:
|
|
891
1383
|
pos_data = positions.get(instance_name, {})
|
|
892
1384
|
last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1385
|
+
|
|
1386
|
+
# Atomic read with position tracking
|
|
1387
|
+
all_messages, new_pos = parse_log_messages(log_file, last_pos, return_end_pos=True)
|
|
1388
|
+
|
|
896
1389
|
# Filter messages:
|
|
897
1390
|
# 1. Exclude own messages
|
|
898
1391
|
# 2. Apply @-mention filtering
|
|
@@ -902,20 +1395,10 @@ def get_new_messages(instance_name):
|
|
|
902
1395
|
if msg['from'] != instance_name:
|
|
903
1396
|
if should_deliver_message(msg, instance_name, all_instance_names):
|
|
904
1397
|
messages.append(msg)
|
|
905
|
-
|
|
906
|
-
# Update position to
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
new_pos = f.tell()
|
|
910
|
-
|
|
911
|
-
# Update position file
|
|
912
|
-
if instance_name not in positions:
|
|
913
|
-
positions[instance_name] = {}
|
|
914
|
-
|
|
915
|
-
positions[instance_name]['pos'] = new_pos
|
|
916
|
-
|
|
917
|
-
atomic_write(pos_file, json.dumps(positions, indent=2))
|
|
918
|
-
|
|
1398
|
+
|
|
1399
|
+
# Update position to what was actually processed
|
|
1400
|
+
update_instance_position(instance_name, {'pos': new_pos})
|
|
1401
|
+
|
|
919
1402
|
return messages
|
|
920
1403
|
|
|
921
1404
|
def format_age(seconds):
|
|
@@ -929,73 +1412,113 @@ def format_age(seconds):
|
|
|
929
1412
|
|
|
930
1413
|
def get_transcript_status(transcript_path):
|
|
931
1414
|
"""Parse transcript to determine current Claude state"""
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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:
|
|
937
1427
|
lines = f.readlines()[-5:]
|
|
938
|
-
|
|
939
|
-
for line in reversed(lines):
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
if '
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
+
|
|
960
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
|
|
961
1464
|
except Exception:
|
|
962
1465
|
return "inactive", "", "", 0
|
|
963
1466
|
|
|
964
1467
|
def get_instance_status(pos_data):
|
|
965
1468
|
"""Get current status of instance"""
|
|
966
1469
|
now = int(time.time())
|
|
967
|
-
wait_timeout = get_config_value('wait_timeout', 1800)
|
|
968
|
-
|
|
1470
|
+
wait_timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
|
|
1471
|
+
|
|
1472
|
+
# Check if process is still alive. pid: null means killed
|
|
1473
|
+
# All real instances should have a PID (set by update_instance_with_pid)
|
|
1474
|
+
if 'pid' in pos_data:
|
|
1475
|
+
pid = pos_data['pid']
|
|
1476
|
+
if pid is None:
|
|
1477
|
+
# Explicitly null = was killed
|
|
1478
|
+
return "inactive", ""
|
|
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
|
+
|
|
969
1484
|
last_permission = pos_data.get("last_permission_request", 0)
|
|
970
1485
|
last_stop = pos_data.get("last_stop", 0)
|
|
971
1486
|
last_tool = pos_data.get("last_tool", 0)
|
|
972
|
-
|
|
1487
|
+
|
|
973
1488
|
transcript_timestamp = 0
|
|
974
1489
|
transcript_status = "inactive"
|
|
975
|
-
|
|
1490
|
+
|
|
976
1491
|
transcript_path = pos_data.get("transcript_path", "")
|
|
977
1492
|
if transcript_path:
|
|
978
1493
|
status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
|
|
979
1494
|
transcript_status = status
|
|
980
|
-
|
|
1495
|
+
|
|
1496
|
+
# Calculate last actual activity (excluding heartbeat)
|
|
1497
|
+
last_activity = max(last_permission, last_tool, transcript_timestamp)
|
|
1498
|
+
|
|
1499
|
+
# Check timeout based on actual activity
|
|
1500
|
+
if last_activity > 0 and (now - last_activity) > wait_timeout:
|
|
1501
|
+
return "inactive", ""
|
|
1502
|
+
|
|
1503
|
+
# Now determine current status including heartbeat
|
|
981
1504
|
events = [
|
|
982
1505
|
(last_permission, "blocked"),
|
|
983
|
-
(last_stop, "waiting"),
|
|
1506
|
+
(last_stop, "waiting"),
|
|
984
1507
|
(last_tool, "inactive"),
|
|
985
1508
|
(transcript_timestamp, transcript_status)
|
|
986
1509
|
]
|
|
987
|
-
|
|
1510
|
+
|
|
988
1511
|
recent_events = [(ts, status) for ts, status in events if ts > 0]
|
|
989
1512
|
if not recent_events:
|
|
990
1513
|
return "inactive", ""
|
|
991
|
-
|
|
1514
|
+
|
|
992
1515
|
most_recent_time, most_recent_status = max(recent_events)
|
|
993
1516
|
age = now - most_recent_time
|
|
994
|
-
|
|
995
|
-
if
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
return
|
|
1517
|
+
|
|
1518
|
+
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1519
|
+
final_result = (most_recent_status, f"({format_age(age)}){status_suffix}")
|
|
1520
|
+
|
|
1521
|
+
return final_result
|
|
999
1522
|
|
|
1000
1523
|
def get_status_block(status_type):
|
|
1001
1524
|
"""Get colored status block for a status type"""
|
|
@@ -1046,38 +1569,18 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1046
1569
|
available_height = get_terminal_height() - 20
|
|
1047
1570
|
limit = max(2, available_height // 2)
|
|
1048
1571
|
|
|
1049
|
-
log_file =
|
|
1572
|
+
log_file = hcom_path(LOG_FILE)
|
|
1050
1573
|
if log_file.exists():
|
|
1051
1574
|
messages = parse_log_messages(log_file)
|
|
1052
1575
|
show_recent_messages(messages, limit, truncate=True)
|
|
1053
1576
|
|
|
1054
|
-
def show_instances_status():
|
|
1055
|
-
"""Show status of all instances"""
|
|
1056
|
-
pos_file = get_hcom_dir() / "hcom.json"
|
|
1057
|
-
if not pos_file.exists():
|
|
1058
|
-
print(f" {DIM}No Claude instances connected{RESET}")
|
|
1059
|
-
return
|
|
1060
|
-
|
|
1061
|
-
positions = load_positions(pos_file)
|
|
1062
|
-
if not positions:
|
|
1063
|
-
print(f" {DIM}No Claude instances connected{RESET}")
|
|
1064
|
-
return
|
|
1065
|
-
|
|
1066
|
-
print("Instances in hcom:")
|
|
1067
|
-
for instance_name, pos_data in positions.items():
|
|
1068
|
-
status_type, age = get_instance_status(pos_data)
|
|
1069
|
-
status_block = get_status_block(status_type)
|
|
1070
|
-
directory = pos_data.get("directory", "unknown")
|
|
1071
|
-
print(f" {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}{RESET} {directory}")
|
|
1072
|
-
|
|
1073
1577
|
def show_instances_by_directory():
|
|
1074
1578
|
"""Show instances organized by their working directories"""
|
|
1075
|
-
|
|
1076
|
-
if not
|
|
1579
|
+
positions = load_all_positions()
|
|
1580
|
+
if not positions:
|
|
1077
1581
|
print(f" {DIM}No Claude instances connected{RESET}")
|
|
1078
1582
|
return
|
|
1079
1583
|
|
|
1080
|
-
positions = load_positions(pos_file)
|
|
1081
1584
|
if positions:
|
|
1082
1585
|
directories = {}
|
|
1083
1586
|
for instance_name, pos_data in positions.items():
|
|
@@ -1095,7 +1598,9 @@ def show_instances_by_directory():
|
|
|
1095
1598
|
last_tool_name = pos_data.get("last_tool_name", "unknown")
|
|
1096
1599
|
last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
|
|
1097
1600
|
|
|
1098
|
-
|
|
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
|
|
1099
1604
|
session_info = f" | {sid}" if sid else ""
|
|
1100
1605
|
|
|
1101
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}")
|
|
@@ -1112,7 +1617,7 @@ def alt_screen_detailed_status_and_input():
|
|
|
1112
1617
|
print(f"{BOLD} HCOM DETAILED STATUS{RESET}")
|
|
1113
1618
|
print(f"{BOLD}{'=' * 70}{RESET}")
|
|
1114
1619
|
print(f"{FG_CYAN} HCOM: GLOBAL CHAT{RESET}")
|
|
1115
|
-
print(f"{DIM} LOG FILE: {
|
|
1620
|
+
print(f"{DIM} LOG FILE: {hcom_path(LOG_FILE)}{RESET}")
|
|
1116
1621
|
print(f"{DIM} UPDATED: {timestamp}{RESET}")
|
|
1117
1622
|
print(f"{BOLD}{'-' * 70}{RESET}")
|
|
1118
1623
|
print()
|
|
@@ -1138,33 +1643,31 @@ def alt_screen_detailed_status_and_input():
|
|
|
1138
1643
|
|
|
1139
1644
|
def get_status_summary():
|
|
1140
1645
|
"""Get a one-line summary of all instance statuses"""
|
|
1141
|
-
|
|
1142
|
-
if not pos_file.exists():
|
|
1143
|
-
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1144
|
-
|
|
1145
|
-
positions = load_positions(pos_file)
|
|
1646
|
+
positions = load_all_positions()
|
|
1146
1647
|
if not positions:
|
|
1147
1648
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1148
|
-
|
|
1649
|
+
|
|
1149
1650
|
status_counts = {"thinking": 0, "responding": 0, "executing": 0, "waiting": 0, "blocked": 0, "inactive": 0}
|
|
1150
|
-
|
|
1151
|
-
for
|
|
1651
|
+
|
|
1652
|
+
for instance_name, pos_data in positions.items():
|
|
1152
1653
|
status_type, _ = get_instance_status(pos_data)
|
|
1153
1654
|
if status_type in status_counts:
|
|
1154
1655
|
status_counts[status_type] += 1
|
|
1155
|
-
|
|
1656
|
+
|
|
1156
1657
|
parts = []
|
|
1157
1658
|
status_order = ["thinking", "responding", "executing", "waiting", "blocked", "inactive"]
|
|
1158
|
-
|
|
1659
|
+
|
|
1159
1660
|
for status_type in status_order:
|
|
1160
1661
|
count = status_counts[status_type]
|
|
1161
1662
|
if count > 0:
|
|
1162
1663
|
color, symbol = STATUS_MAP[status_type]
|
|
1163
1664
|
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
1164
|
-
|
|
1165
|
-
|
|
1665
|
+
part = f"{text_color}{BOLD}{color} {count} {symbol} {RESET}"
|
|
1666
|
+
parts.append(part)
|
|
1667
|
+
|
|
1166
1668
|
if parts:
|
|
1167
|
-
|
|
1669
|
+
result = "".join(parts)
|
|
1670
|
+
return result
|
|
1168
1671
|
else:
|
|
1169
1672
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1170
1673
|
|
|
@@ -1179,94 +1682,130 @@ def log_line_with_status(message, status):
|
|
|
1179
1682
|
sys.stdout.write("\033[K" + status)
|
|
1180
1683
|
sys.stdout.flush()
|
|
1181
1684
|
|
|
1182
|
-
def initialize_instance_in_position_file(instance_name,
|
|
1183
|
-
"""Initialize
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
if instance_name not in positions:
|
|
1189
|
-
positions[instance_name] = {
|
|
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 = {
|
|
1190
1691
|
"pos": 0,
|
|
1191
1692
|
"directory": str(Path.cwd()),
|
|
1192
|
-
"conversation_uuid": conversation_uuid or "unknown",
|
|
1193
1693
|
"last_tool": 0,
|
|
1194
1694
|
"last_tool_name": "unknown",
|
|
1195
1695
|
"last_stop": 0,
|
|
1196
1696
|
"last_permission_request": 0,
|
|
1697
|
+
"session_ids": [session_id] if session_id else [],
|
|
1197
1698
|
"transcript_path": "",
|
|
1198
|
-
"
|
|
1199
|
-
"
|
|
1200
|
-
"notification_message": ""
|
|
1699
|
+
"notification_message": "",
|
|
1700
|
+
"alias_announced": False
|
|
1201
1701
|
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
# Migration of data only happens if old name exists
|
|
1211
|
-
pos_file = get_hcom_dir() / "hcom.json"
|
|
1212
|
-
positions = load_positions(pos_file)
|
|
1213
|
-
if instance_name in positions:
|
|
1214
|
-
# Copy over the old instance data to new name
|
|
1215
|
-
positions[new_instance] = positions.pop(instance_name)
|
|
1216
|
-
# Update the conversation UUID in the migrated data
|
|
1217
|
-
positions[new_instance]["conversation_uuid"] = conversation_uuid
|
|
1218
|
-
atomic_write(pos_file, json.dumps(positions, indent=2))
|
|
1219
|
-
return new_instance
|
|
1220
|
-
return instance_name
|
|
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
|
|
1221
1710
|
|
|
1222
1711
|
def update_instance_position(instance_name, update_fields):
|
|
1223
|
-
"""Update instance position
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
#
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1712
|
+
"""Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
|
|
1713
|
+
try:
|
|
1714
|
+
data = load_instance_position(instance_name)
|
|
1715
|
+
|
|
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}"
|
|
1247
1808
|
|
|
1248
|
-
def check_and_show_first_use_help(instance_name):
|
|
1249
|
-
"""Check and show first-use help if needed"""
|
|
1250
|
-
|
|
1251
|
-
pos_file = get_hcom_dir() / "hcom.json"
|
|
1252
|
-
positions = load_positions(pos_file)
|
|
1253
|
-
|
|
1254
|
-
instance_data = positions.get(instance_name, {})
|
|
1255
|
-
if not instance_data.get('help_shown', False):
|
|
1256
|
-
# Mark help as shown
|
|
1257
|
-
update_instance_position(instance_name, {'help_shown': True})
|
|
1258
|
-
|
|
1259
|
-
# Get values using unified config system
|
|
1260
|
-
first_use_text = get_config_value('first_use_text', '')
|
|
1261
|
-
instance_hints = get_config_value('instance_hints', '')
|
|
1262
|
-
|
|
1263
|
-
help_text = f"Welcome! hcom chat active. Your alias: {instance_name}. " \
|
|
1264
|
-
f"Send messages: echo \"HCOM_SEND:your message\". " \
|
|
1265
|
-
f"{first_use_text} {instance_hints}".strip()
|
|
1266
|
-
|
|
1267
|
-
output = {"decision": HOOK_DECISION_BLOCK, "reason": help_text}
|
|
1268
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
1269
|
-
sys.exit(EXIT_BLOCK)
|
|
1270
1809
|
|
|
1271
1810
|
# ==================== Command Functions ====================
|
|
1272
1811
|
|
|
@@ -1274,7 +1813,7 @@ def show_main_screen_header():
|
|
|
1274
1813
|
"""Show header for main screen"""
|
|
1275
1814
|
sys.stdout.write("\033[2J\033[H")
|
|
1276
1815
|
|
|
1277
|
-
log_file =
|
|
1816
|
+
log_file = hcom_path(LOG_FILE)
|
|
1278
1817
|
all_messages = []
|
|
1279
1818
|
if log_file.exists():
|
|
1280
1819
|
all_messages = parse_log_messages(log_file)
|
|
@@ -1290,6 +1829,15 @@ def show_main_screen_header():
|
|
|
1290
1829
|
|
|
1291
1830
|
return all_messages
|
|
1292
1831
|
|
|
1832
|
+
def show_cli_hints(to_stderr=True):
|
|
1833
|
+
"""Show CLI hints if configured"""
|
|
1834
|
+
cli_hints = get_config_value('cli_hints', '')
|
|
1835
|
+
if cli_hints:
|
|
1836
|
+
if to_stderr:
|
|
1837
|
+
print(f"\n{cli_hints}", file=sys.stderr)
|
|
1838
|
+
else:
|
|
1839
|
+
print(f"\n{cli_hints}")
|
|
1840
|
+
|
|
1293
1841
|
def cmd_help():
|
|
1294
1842
|
"""Show help text"""
|
|
1295
1843
|
# Basic help for interactive users
|
|
@@ -1299,41 +1847,51 @@ Usage:
|
|
|
1299
1847
|
hcom open [n] Launch n Claude instances
|
|
1300
1848
|
hcom open <agent> Launch named agent from .claude/agents/
|
|
1301
1849
|
hcom open --prefix <team> n Launch n instances with team prefix
|
|
1850
|
+
hcom open --background Launch instances as background processes (-p also works)
|
|
1851
|
+
hcom open --claude-args "--model sonnet" Pass claude code CLI flags
|
|
1302
1852
|
hcom watch View conversation dashboard
|
|
1303
1853
|
hcom clear Clear and archive conversation
|
|
1304
1854
|
hcom cleanup Remove hooks from current directory
|
|
1305
1855
|
hcom cleanup --all Remove hooks from all tracked directories
|
|
1856
|
+
hcom kill [instance alias] Kill specific instance
|
|
1857
|
+
hcom kill --all Kill all running instances
|
|
1306
1858
|
hcom help Show this help
|
|
1307
1859
|
|
|
1308
1860
|
Automation:
|
|
1309
1861
|
hcom send 'msg' Send message to all
|
|
1310
1862
|
hcom send '@prefix msg' Send to specific instances
|
|
1311
|
-
hcom watch --logs Show
|
|
1312
|
-
hcom watch --status Show status
|
|
1313
|
-
hcom watch --wait [
|
|
1863
|
+
hcom watch --logs Show conversation log
|
|
1864
|
+
hcom watch --status Show status of instances
|
|
1865
|
+
hcom watch --wait [seconds] Wait for new messages (default 60s)
|
|
1314
1866
|
|
|
1315
1867
|
Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
|
|
1316
|
-
|
|
1317
|
-
# Additional help for AI assistants
|
|
1318
|
-
if not sys.stdin.isatty():
|
|
1868
|
+
|
|
1869
|
+
# Additional help for AI assistants
|
|
1870
|
+
if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
|
|
1319
1871
|
print("""
|
|
1320
1872
|
|
|
1321
1873
|
=== ADDITIONAL INFO ===
|
|
1322
1874
|
|
|
1323
1875
|
CONCEPT: HCOM creates multi-agent collaboration by launching multiple Claude Code
|
|
1324
|
-
instances in separate terminals that share a
|
|
1876
|
+
instances in separate terminals that share a group chat.
|
|
1325
1877
|
|
|
1326
1878
|
KEY UNDERSTANDING:
|
|
1327
1879
|
• Single conversation - All instances share ~/.hcom/hcom.log
|
|
1328
|
-
•
|
|
1329
|
-
•
|
|
1330
|
-
• hcom
|
|
1880
|
+
• CLI usage - Use 'hcom send' for messaging. Internal instances know to use 'echo HCOM_SEND:'
|
|
1881
|
+
• hcom open is directory-specific - always cd to project directory first
|
|
1882
|
+
• hcom watch --wait outputs existing logs, then waits for the next message, prints it, and exits.
|
|
1883
|
+
Times out after [seconds]
|
|
1884
|
+
• Named agents are custom system prompts created by users/claude code.
|
|
1885
|
+
"reviewer" named agent loads .claude/agents/reviewer.md (if it was ever created)
|
|
1331
1886
|
|
|
1332
1887
|
LAUNCH PATTERNS:
|
|
1333
1888
|
hcom open 2 reviewer # 2 generic + 1 reviewer agent
|
|
1334
1889
|
hcom open reviewer reviewer # 2 separate reviewer instances
|
|
1335
1890
|
hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
|
|
1336
|
-
hcom open
|
|
1891
|
+
hcom open --claude-args "--model sonnet" # Pass 'claude' CLI flags
|
|
1892
|
+
hcom open --background (or -p) then hcom kill # Detached background process
|
|
1893
|
+
hcom watch --status (get sessionid) then hcom open --claude-args "--resume <sessionid>"
|
|
1894
|
+
HCOM_INITIAL_PROMPT="do x task" hcom open # initial prompt to instance
|
|
1337
1895
|
|
|
1338
1896
|
@MENTION TARGETING:
|
|
1339
1897
|
hcom send "message" # Broadcasts to everyone
|
|
@@ -1343,29 +1901,52 @@ LAUNCH PATTERNS:
|
|
|
1343
1901
|
|
|
1344
1902
|
STATUS INDICATORS:
|
|
1345
1903
|
• ◉ thinking, ▷ responding, ▶ executing - instance is working
|
|
1346
|
-
• ◉ waiting - instance is waiting for new messages
|
|
1904
|
+
• ◉ waiting - instance is waiting for new messages
|
|
1347
1905
|
• ■ blocked - instance is blocked by permission request (needs user approval)
|
|
1348
|
-
• ○ inactive - instance is
|
|
1906
|
+
• ○ inactive - instance is timed out, disconnected, etc
|
|
1349
1907
|
|
|
1350
1908
|
CONFIG:
|
|
1351
|
-
Environment overrides (temporary): HCOM_INSTANCE_HINTS="useful info" hcom send "hi"
|
|
1352
1909
|
Config file (persistent): ~/.hcom/config.json
|
|
1353
1910
|
|
|
1354
|
-
Key settings (
|
|
1355
|
-
terminal_mode: "new_window" | "same_terminal" | "show_commands"
|
|
1356
|
-
initial_prompt: "Say hi in chat", first_use_text: "Essential
|
|
1357
|
-
instance_hints: "", cli_hints: "" # Extra info for instances/CLI
|
|
1911
|
+
Key settings (full list in config.json):
|
|
1912
|
+
terminal_mode: "new_window" (default) | "same_terminal" | "show_commands"
|
|
1913
|
+
initial_prompt: "Say hi in chat", first_use_text: "Essential messages only..."
|
|
1914
|
+
instance_hints: "text", cli_hints: "text" # Extra info for instances/CLI
|
|
1915
|
+
env_overrides: "custom environment variables for instances"
|
|
1916
|
+
|
|
1917
|
+
Temporary environment overrides for any setting (all caps & append HCOM_):
|
|
1918
|
+
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages recieved by instance
|
|
1919
|
+
export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
|
|
1920
|
+
|
|
1921
|
+
EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
|
|
1922
|
+
with 'hcom watch --status'. Instances respond automatically in shared chat.
|
|
1923
|
+
|
|
1924
|
+
Run 'claude --help' to see all claude code CLI flags.""")
|
|
1925
|
+
|
|
1926
|
+
show_cli_hints(to_stderr=False)
|
|
1927
|
+
else:
|
|
1928
|
+
if not IS_WINDOWS:
|
|
1929
|
+
print("\nFor additional info & examples: hcom --help | cat")
|
|
1358
1930
|
|
|
1359
|
-
EXPECT: Instance names are auto-generated (5-char format based on uuid: "hova7"). Check actual names
|
|
1360
|
-
with 'hcom watch --status'. Instances respond automatically in shared chat.""")
|
|
1361
|
-
|
|
1362
1931
|
return 0
|
|
1363
1932
|
|
|
1364
1933
|
def cmd_open(*args):
|
|
1365
1934
|
"""Launch Claude instances with chat enabled"""
|
|
1366
1935
|
try:
|
|
1367
1936
|
# Parse arguments
|
|
1368
|
-
instances, prefix, claude_args = parse_open_args(list(args))
|
|
1937
|
+
instances, prefix, claude_args, background = parse_open_args(list(args))
|
|
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
|
+
|
|
1947
|
+
# Add -p flag and stream-json output for background mode if not already present
|
|
1948
|
+
if background and '-p' not in claude_args and '--print' not in claude_args:
|
|
1949
|
+
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
1369
1950
|
|
|
1370
1951
|
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
1371
1952
|
|
|
@@ -1383,23 +1964,29 @@ def cmd_open(*args):
|
|
|
1383
1964
|
print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
|
|
1384
1965
|
return 1
|
|
1385
1966
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1967
|
+
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
1968
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1969
|
+
instances_dir.mkdir(exist_ok=True)
|
|
1389
1970
|
|
|
1390
1971
|
if not log_file.exists():
|
|
1391
1972
|
log_file.touch()
|
|
1392
|
-
if not pos_file.exists():
|
|
1393
|
-
atomic_write(pos_file, json.dumps({}, indent=2))
|
|
1394
1973
|
|
|
1395
1974
|
# Build environment variables for Claude instances
|
|
1396
1975
|
base_env = build_claude_env()
|
|
1397
|
-
|
|
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
|
+
|
|
1398
1984
|
# Add prefix-specific hints if provided
|
|
1399
1985
|
if prefix:
|
|
1400
|
-
|
|
1986
|
+
base_env['HCOM_PREFIX'] = prefix
|
|
1987
|
+
hint = f"To respond to {prefix} group: echo 'HCOM_SEND:@{prefix} message'"
|
|
1401
1988
|
base_env['HCOM_INSTANCE_HINTS'] = hint
|
|
1402
|
-
|
|
1989
|
+
|
|
1403
1990
|
first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
|
|
1404
1991
|
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
1405
1992
|
|
|
@@ -1408,7 +1995,19 @@ def cmd_open(*args):
|
|
|
1408
1995
|
|
|
1409
1996
|
temp_files_to_cleanup = []
|
|
1410
1997
|
|
|
1411
|
-
for instance_type in instances:
|
|
1998
|
+
for idx, instance_type in enumerate(instances):
|
|
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
|
|
2004
|
+
|
|
2005
|
+
# Mark background instances via environment with log filename
|
|
2006
|
+
if background:
|
|
2007
|
+
# Generate unique log filename
|
|
2008
|
+
log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
|
|
2009
|
+
instance_env['HCOM_BACKGROUND'] = log_filename
|
|
2010
|
+
|
|
1412
2011
|
# Build claude command
|
|
1413
2012
|
if instance_type == 'generic':
|
|
1414
2013
|
# Generic instance - no agent content
|
|
@@ -1421,6 +2020,9 @@ def cmd_open(*args):
|
|
|
1421
2020
|
# Agent instance
|
|
1422
2021
|
try:
|
|
1423
2022
|
agent_content, agent_config = resolve_agent(instance_type)
|
|
2023
|
+
# Prepend agent instance awareness to system prompt
|
|
2024
|
+
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
2025
|
+
agent_content = agent_prefix + agent_content
|
|
1424
2026
|
# Use agent's model and tools if specified and not overridden in claude_args
|
|
1425
2027
|
agent_model = agent_config.get('model')
|
|
1426
2028
|
agent_tools = agent_config.get('tools')
|
|
@@ -1438,24 +2040,20 @@ def cmd_open(*args):
|
|
|
1438
2040
|
continue
|
|
1439
2041
|
|
|
1440
2042
|
try:
|
|
1441
|
-
|
|
1442
|
-
|
|
2043
|
+
if background:
|
|
2044
|
+
log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
|
|
2045
|
+
if log_file:
|
|
2046
|
+
print(f"Background instance launched, log: {log_file}")
|
|
2047
|
+
launched += 1
|
|
2048
|
+
else:
|
|
2049
|
+
launch_terminal(claude_cmd, instance_env, cwd=os.getcwd())
|
|
2050
|
+
launched += 1
|
|
1443
2051
|
except Exception as e:
|
|
1444
|
-
print(f"
|
|
2052
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
1445
2053
|
|
|
1446
2054
|
# Clean up temp files after a delay (let terminals read them first)
|
|
1447
2055
|
if temp_files_to_cleanup:
|
|
1448
|
-
|
|
1449
|
-
time.sleep(5) # Give terminals time to read the files
|
|
1450
|
-
for temp_file in temp_files_to_cleanup:
|
|
1451
|
-
try:
|
|
1452
|
-
os.unlink(temp_file)
|
|
1453
|
-
except:
|
|
1454
|
-
pass
|
|
1455
|
-
|
|
1456
|
-
cleanup_thread = threading.Thread(target=cleanup_temp_files)
|
|
1457
|
-
cleanup_thread.daemon = True
|
|
1458
|
-
cleanup_thread.start()
|
|
2056
|
+
schedule_file_cleanup(temp_files_to_cleanup)
|
|
1459
2057
|
|
|
1460
2058
|
if launched == 0:
|
|
1461
2059
|
print(format_error("No instances launched"), file=sys.stderr)
|
|
@@ -1463,30 +2061,47 @@ def cmd_open(*args):
|
|
|
1463
2061
|
|
|
1464
2062
|
# Success message
|
|
1465
2063
|
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
|
1476
2091
|
|
|
1477
2092
|
except ValueError as e:
|
|
1478
|
-
print(
|
|
2093
|
+
print(str(e), file=sys.stderr)
|
|
1479
2094
|
return 1
|
|
1480
2095
|
except Exception as e:
|
|
1481
|
-
print(
|
|
2096
|
+
print(str(e), file=sys.stderr)
|
|
1482
2097
|
return 1
|
|
1483
2098
|
|
|
1484
2099
|
def cmd_watch(*args):
|
|
1485
2100
|
"""View conversation dashboard"""
|
|
1486
|
-
log_file =
|
|
1487
|
-
|
|
2101
|
+
log_file = hcom_path(LOG_FILE)
|
|
2102
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1488
2103
|
|
|
1489
|
-
if not log_file.exists() and not
|
|
2104
|
+
if not log_file.exists() and not instances_dir.exists():
|
|
1490
2105
|
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
1491
2106
|
return 1
|
|
1492
2107
|
|
|
@@ -1495,72 +2110,147 @@ def cmd_watch(*args):
|
|
|
1495
2110
|
show_status = False
|
|
1496
2111
|
wait_timeout = None
|
|
1497
2112
|
|
|
1498
|
-
|
|
2113
|
+
i = 0
|
|
2114
|
+
while i < len(args):
|
|
2115
|
+
arg = args[i]
|
|
1499
2116
|
if arg == '--logs':
|
|
1500
2117
|
show_logs = True
|
|
1501
2118
|
elif arg == '--status':
|
|
1502
2119
|
show_status = True
|
|
1503
|
-
elif arg.startswith('--wait='):
|
|
1504
|
-
try:
|
|
1505
|
-
wait_timeout = int(arg.split('=')[1])
|
|
1506
|
-
except ValueError:
|
|
1507
|
-
print(format_error("Invalid timeout value"), file=sys.stderr)
|
|
1508
|
-
return 1
|
|
1509
2120
|
elif arg == '--wait':
|
|
1510
|
-
#
|
|
1511
|
-
|
|
2121
|
+
# Check if next arg is a number
|
|
2122
|
+
if i + 1 < len(args) and args[i + 1].isdigit():
|
|
2123
|
+
wait_timeout = int(args[i + 1])
|
|
2124
|
+
i += 1 # Skip the number
|
|
2125
|
+
else:
|
|
2126
|
+
wait_timeout = 60 # Default
|
|
2127
|
+
i += 1
|
|
2128
|
+
|
|
2129
|
+
# If wait is specified, enable logs to show the messages
|
|
2130
|
+
if wait_timeout is not None:
|
|
2131
|
+
show_logs = True
|
|
1512
2132
|
|
|
1513
2133
|
# Non-interactive mode (no TTY or flags specified)
|
|
1514
2134
|
if not is_interactive() or show_logs or show_status:
|
|
1515
2135
|
if show_logs:
|
|
2136
|
+
# Atomic position capture BEFORE parsing (prevents race condition)
|
|
1516
2137
|
if log_file.exists():
|
|
2138
|
+
last_pos = log_file.stat().st_size # Capture position first
|
|
1517
2139
|
messages = parse_log_messages(log_file)
|
|
1518
|
-
for msg in messages:
|
|
1519
|
-
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
1520
2140
|
else:
|
|
1521
|
-
|
|
1522
|
-
|
|
2141
|
+
last_pos = 0
|
|
2142
|
+
messages = []
|
|
2143
|
+
|
|
2144
|
+
# If --wait, show only recent messages to prevent context bloat
|
|
1523
2145
|
if wait_timeout is not None:
|
|
1524
|
-
|
|
1525
|
-
|
|
2146
|
+
cutoff = datetime.now() - timedelta(seconds=5)
|
|
2147
|
+
recent_messages = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
|
|
2148
|
+
|
|
2149
|
+
# Status to stderr, data to stdout
|
|
2150
|
+
if recent_messages:
|
|
2151
|
+
print(f'---Showing last 5 seconds of messages---', file=sys.stderr)
|
|
2152
|
+
for msg in recent_messages:
|
|
2153
|
+
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2154
|
+
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
2155
|
+
else:
|
|
2156
|
+
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
1526
2157
|
|
|
2158
|
+
|
|
2159
|
+
# Wait loop
|
|
2160
|
+
start_time = time.time()
|
|
1527
2161
|
while time.time() - start_time < wait_timeout:
|
|
1528
|
-
if log_file.exists()
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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)
|
|
2168
|
+
if new_messages:
|
|
2169
|
+
for msg in new_messages:
|
|
2170
|
+
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2171
|
+
last_pos = current_size # Update only after successful processing
|
|
2172
|
+
return 0 # Success - got new messages
|
|
2173
|
+
if current_size > last_pos:
|
|
2174
|
+
last_pos = current_size # Update even if no messages (file grew but no complete messages yet)
|
|
2175
|
+
time.sleep(0.1)
|
|
2176
|
+
|
|
2177
|
+
# Timeout message to stderr
|
|
2178
|
+
print(f'[TIMED OUT] No new messages received after {wait_timeout} seconds.', file=sys.stderr)
|
|
2179
|
+
return 1 # Timeout - no new messages
|
|
2180
|
+
|
|
2181
|
+
# Regular --logs (no --wait): print all messages to stdout
|
|
2182
|
+
else:
|
|
2183
|
+
if messages:
|
|
2184
|
+
for msg in messages:
|
|
2185
|
+
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2186
|
+
else:
|
|
2187
|
+
print("No messages yet", file=sys.stderr)
|
|
2188
|
+
|
|
2189
|
+
show_cli_hints()
|
|
1535
2190
|
|
|
1536
2191
|
elif show_status:
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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))
|
|
2228
|
+
show_cli_hints()
|
|
1543
2229
|
else:
|
|
1544
|
-
|
|
1545
|
-
print("
|
|
1546
|
-
print(" hcom
|
|
1547
|
-
print(" hcom watch --
|
|
1548
|
-
print(" hcom watch --
|
|
2230
|
+
print("No TTY - Automation usage:", file=sys.stderr)
|
|
2231
|
+
print(" hcom send 'message' Send message to chat", file=sys.stderr)
|
|
2232
|
+
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
2233
|
+
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
2234
|
+
print(" hcom watch --wait Wait for new messages", file=sys.stderr)
|
|
2235
|
+
|
|
2236
|
+
show_cli_hints()
|
|
1549
2237
|
|
|
1550
2238
|
return 0
|
|
1551
2239
|
|
|
1552
2240
|
# Interactive dashboard mode
|
|
1553
|
-
last_pos = 0
|
|
1554
2241
|
status_suffix = f"{DIM} [⏎]...{RESET}"
|
|
1555
2242
|
|
|
2243
|
+
# Atomic position capture BEFORE showing messages (prevents race condition)
|
|
2244
|
+
if log_file.exists():
|
|
2245
|
+
last_pos = log_file.stat().st_size
|
|
2246
|
+
else:
|
|
2247
|
+
last_pos = 0
|
|
2248
|
+
|
|
1556
2249
|
all_messages = show_main_screen_header()
|
|
1557
2250
|
|
|
1558
2251
|
show_recent_messages(all_messages, limit=5)
|
|
1559
2252
|
print(f"\n{DIM}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
|
|
1560
2253
|
|
|
1561
|
-
if log_file.exists():
|
|
1562
|
-
last_pos = log_file.stat().st_size
|
|
1563
|
-
|
|
1564
2254
|
# Print newline to ensure status starts on its own line
|
|
1565
2255
|
print()
|
|
1566
2256
|
|
|
@@ -1573,7 +2263,7 @@ def cmd_watch(*args):
|
|
|
1573
2263
|
try:
|
|
1574
2264
|
while True:
|
|
1575
2265
|
now = time.time()
|
|
1576
|
-
if now - last_status_update >
|
|
2266
|
+
if now - last_status_update > 0.1: # 100ms
|
|
1577
2267
|
current_status = get_status_summary()
|
|
1578
2268
|
|
|
1579
2269
|
# Only redraw if status text changed
|
|
@@ -1583,13 +2273,15 @@ def cmd_watch(*args):
|
|
|
1583
2273
|
|
|
1584
2274
|
last_status_update = now
|
|
1585
2275
|
|
|
1586
|
-
if log_file.exists()
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|
|
1593
2285
|
|
|
1594
2286
|
# Check for keyboard input
|
|
1595
2287
|
ready_for_input = False
|
|
@@ -1635,51 +2327,66 @@ def cmd_watch(*args):
|
|
|
1635
2327
|
|
|
1636
2328
|
def cmd_clear():
|
|
1637
2329
|
"""Clear and archive conversation"""
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
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
|
+
|
|
1642
2341
|
# Check if hcom files exist
|
|
1643
|
-
if not log_file.exists() and not
|
|
2342
|
+
if not log_file.exists() and not instances_dir.exists():
|
|
1644
2343
|
print("No hcom conversation to clear")
|
|
1645
2344
|
return 0
|
|
1646
|
-
|
|
1647
|
-
# Generate archive timestamp
|
|
1648
|
-
timestamp = get_archive_timestamp()
|
|
1649
|
-
|
|
2345
|
+
|
|
1650
2346
|
# Archive existing files if they have content
|
|
2347
|
+
timestamp = get_archive_timestamp()
|
|
1651
2348
|
archived = False
|
|
1652
|
-
|
|
2349
|
+
|
|
1653
2350
|
try:
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
archive_log = get_hcom_dir() / f'hcom-{timestamp}.log'
|
|
1657
|
-
log_file.rename(archive_log)
|
|
1658
|
-
archived = True
|
|
1659
|
-
elif log_file.exists():
|
|
1660
|
-
log_file.unlink()
|
|
2351
|
+
has_log = log_file.exists() and log_file.stat().st_size > 0
|
|
2352
|
+
has_instances = instances_dir.exists() and any(instances_dir.glob('*.json'))
|
|
1661
2353
|
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
2354
|
+
if has_log or has_instances:
|
|
2355
|
+
# Create session archive folder with timestamp
|
|
2356
|
+
session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
|
|
2357
|
+
session_archive.mkdir(exist_ok=True)
|
|
2358
|
+
|
|
2359
|
+
# Archive log file
|
|
2360
|
+
if has_log:
|
|
2361
|
+
archive_log = session_archive / LOG_FILE
|
|
2362
|
+
log_file.rename(archive_log)
|
|
2363
|
+
archived = True
|
|
2364
|
+
elif log_file.exists():
|
|
2365
|
+
log_file.unlink()
|
|
2366
|
+
|
|
2367
|
+
# Archive instances
|
|
2368
|
+
if has_instances:
|
|
2369
|
+
archive_instances = session_archive / INSTANCES_DIR
|
|
2370
|
+
archive_instances.mkdir(exist_ok=True)
|
|
2371
|
+
|
|
2372
|
+
# Move json files only
|
|
2373
|
+
for f in instances_dir.glob('*.json'):
|
|
2374
|
+
f.rename(archive_instances / f.name)
|
|
2375
|
+
|
|
2376
|
+
archived = True
|
|
2377
|
+
else:
|
|
2378
|
+
# Clean up empty files/dirs
|
|
2379
|
+
if log_file.exists():
|
|
2380
|
+
log_file.unlink()
|
|
2381
|
+
if instances_dir.exists():
|
|
2382
|
+
shutil.rmtree(instances_dir)
|
|
1676
2383
|
|
|
1677
2384
|
log_file.touch()
|
|
1678
|
-
|
|
2385
|
+
clear_all_positions()
|
|
1679
2386
|
|
|
1680
2387
|
if archived:
|
|
1681
|
-
print(f"Archived
|
|
1682
|
-
print("Started fresh hcom conversation")
|
|
2388
|
+
print(f"Archived to archive/session-{timestamp}/")
|
|
2389
|
+
print("Started fresh hcom conversation log")
|
|
1683
2390
|
return 0
|
|
1684
2391
|
|
|
1685
2392
|
except Exception as e:
|
|
@@ -1699,38 +2406,27 @@ def cleanup_directory_hooks(directory):
|
|
|
1699
2406
|
|
|
1700
2407
|
try:
|
|
1701
2408
|
# Load existing settings
|
|
1702
|
-
|
|
1703
|
-
|
|
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"
|
|
1704
2416
|
|
|
1705
|
-
# Check if any hcom hooks exist
|
|
1706
2417
|
hooks_found = False
|
|
1707
2418
|
|
|
1708
|
-
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
1709
|
-
for event in ['PostToolUse', 'Stop', 'Notification'])
|
|
2419
|
+
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2420
|
+
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
1710
2421
|
|
|
1711
2422
|
_remove_hcom_hooks_from_settings(settings)
|
|
1712
2423
|
|
|
1713
2424
|
# Check if any were removed
|
|
1714
|
-
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
1715
|
-
for event in ['PostToolUse', 'Stop', 'Notification'])
|
|
2425
|
+
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2426
|
+
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
1716
2427
|
if new_hook_count < original_hook_count:
|
|
1717
2428
|
hooks_found = True
|
|
1718
|
-
|
|
1719
|
-
if 'permissions' in settings and 'allow' in settings['permissions']:
|
|
1720
|
-
original_perms = settings['permissions']['allow'][:]
|
|
1721
|
-
settings['permissions']['allow'] = [
|
|
1722
|
-
perm for perm in settings['permissions']['allow']
|
|
1723
|
-
if 'HCOM_SEND' not in perm
|
|
1724
|
-
]
|
|
1725
|
-
|
|
1726
|
-
if len(settings['permissions']['allow']) < len(original_perms):
|
|
1727
|
-
hooks_found = True
|
|
1728
|
-
|
|
1729
|
-
if not settings['permissions']['allow']:
|
|
1730
|
-
del settings['permissions']['allow']
|
|
1731
|
-
if not settings['permissions']:
|
|
1732
|
-
del settings['permissions']
|
|
1733
|
-
|
|
2429
|
+
|
|
1734
2430
|
if not hooks_found:
|
|
1735
2431
|
return 0, "No hcom hooks found"
|
|
1736
2432
|
|
|
@@ -1750,39 +2446,70 @@ def cleanup_directory_hooks(directory):
|
|
|
1750
2446
|
return 1, format_error(f"Cannot modify settings.local.json: {e}")
|
|
1751
2447
|
|
|
1752
2448
|
|
|
2449
|
+
def cmd_kill(*args):
|
|
2450
|
+
"""Kill instances by name or all with --all"""
|
|
2451
|
+
|
|
2452
|
+
instance_name = args[0] if args and args[0] != '--all' else None
|
|
2453
|
+
positions = load_all_positions() if not instance_name else {instance_name: load_instance_position(instance_name)}
|
|
2454
|
+
|
|
2455
|
+
# Filter to instances with PIDs (any instance that's running)
|
|
2456
|
+
targets = [(name, data) for name, data in positions.items() if data.get('pid')]
|
|
2457
|
+
|
|
2458
|
+
if not targets:
|
|
2459
|
+
print(f"No running process found for {instance_name}" if instance_name else "No running instances found")
|
|
2460
|
+
return 1 if instance_name else 0
|
|
2461
|
+
|
|
2462
|
+
killed_count = 0
|
|
2463
|
+
for target_name, target_data in targets:
|
|
2464
|
+
status, age = get_instance_status(target_data)
|
|
2465
|
+
instance_type = "background" if target_data.get('background') else "foreground"
|
|
2466
|
+
|
|
2467
|
+
pid = int(target_data['pid'])
|
|
2468
|
+
try:
|
|
2469
|
+
# Try graceful termination first
|
|
2470
|
+
terminate_process(pid, force=False)
|
|
2471
|
+
|
|
2472
|
+
# Wait for process to exit gracefully
|
|
2473
|
+
for _ in range(20):
|
|
2474
|
+
time.sleep(KILL_CHECK_INTERVAL)
|
|
2475
|
+
if not is_process_alive(pid):
|
|
2476
|
+
# Process terminated successfully
|
|
2477
|
+
break
|
|
2478
|
+
else:
|
|
2479
|
+
# Process didn't die from graceful attempt, force kill
|
|
2480
|
+
terminate_process(pid, force=True)
|
|
2481
|
+
time.sleep(0.1)
|
|
2482
|
+
|
|
2483
|
+
print(f"Killed {target_name} ({instance_type}, {status}{age}, PID {pid})")
|
|
2484
|
+
killed_count += 1
|
|
2485
|
+
except (TypeError, ValueError) as e:
|
|
2486
|
+
print(f"Process {pid} invalid: {e}")
|
|
2487
|
+
|
|
2488
|
+
# Mark instance as killed
|
|
2489
|
+
update_instance_position(target_name, {'pid': None})
|
|
2490
|
+
|
|
2491
|
+
if not instance_name:
|
|
2492
|
+
print(f"Killed {killed_count} instance(s)")
|
|
2493
|
+
|
|
2494
|
+
return 0
|
|
2495
|
+
|
|
1753
2496
|
def cmd_cleanup(*args):
|
|
1754
2497
|
"""Remove hcom hooks from current directory or all directories"""
|
|
1755
2498
|
if args and args[0] == '--all':
|
|
1756
2499
|
directories = set()
|
|
1757
2500
|
|
|
1758
|
-
# Get all directories from current
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
positions = load_positions(pos_file)
|
|
2501
|
+
# Get all directories from current instances
|
|
2502
|
+
try:
|
|
2503
|
+
positions = load_all_positions()
|
|
2504
|
+
if positions:
|
|
1763
2505
|
for instance_data in positions.values():
|
|
1764
2506
|
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
1765
2507
|
directories.add(instance_data['directory'])
|
|
1766
|
-
except Exception as e:
|
|
1767
|
-
print(format_warning(f"Could not read current position file: {e}"))
|
|
1768
|
-
|
|
1769
|
-
hcom_dir = get_hcom_dir()
|
|
1770
|
-
try:
|
|
1771
|
-
# Look for archived position files (hcom-TIMESTAMP.json)
|
|
1772
|
-
for archive_file in hcom_dir.glob('hcom-*.json'):
|
|
1773
|
-
try:
|
|
1774
|
-
with open(archive_file, 'r') as f:
|
|
1775
|
-
archived_positions = json.load(f)
|
|
1776
|
-
for instance_data in archived_positions.values():
|
|
1777
|
-
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
1778
|
-
directories.add(instance_data['directory'])
|
|
1779
|
-
except Exception as e:
|
|
1780
|
-
print(format_warning(f"Could not read archive {archive_file.name}: {e}"))
|
|
1781
2508
|
except Exception as e:
|
|
1782
|
-
print(
|
|
2509
|
+
print(f"Warning: Could not read current instances: {e}")
|
|
1783
2510
|
|
|
1784
2511
|
if not directories:
|
|
1785
|
-
print("No directories found in hcom tracking
|
|
2512
|
+
print("No directories found in current hcom tracking")
|
|
1786
2513
|
return 0
|
|
1787
2514
|
|
|
1788
2515
|
print(f"Found {len(directories)} unique directories to check")
|
|
@@ -1797,17 +2524,10 @@ def cmd_cleanup(*args):
|
|
|
1797
2524
|
continue
|
|
1798
2525
|
|
|
1799
2526
|
print(f"\nChecking {directory}...")
|
|
1800
|
-
|
|
1801
|
-
# Check if settings file exists
|
|
1802
|
-
settings_path = Path(directory) / '.claude' / 'settings.local.json'
|
|
1803
|
-
if not settings_path.exists():
|
|
1804
|
-
print(" No Claude settings found")
|
|
1805
|
-
already_clean += 1
|
|
1806
|
-
continue
|
|
1807
|
-
|
|
2527
|
+
|
|
1808
2528
|
exit_code, message = cleanup_directory_hooks(Path(directory))
|
|
1809
2529
|
if exit_code == 0:
|
|
1810
|
-
if "No hcom hooks found" in message:
|
|
2530
|
+
if "No hcom hooks found" in message or "No Claude settings found" in message:
|
|
1811
2531
|
already_clean += 1
|
|
1812
2532
|
print(f" {message}")
|
|
1813
2533
|
else:
|
|
@@ -1833,29 +2553,29 @@ def cmd_cleanup(*args):
|
|
|
1833
2553
|
def cmd_send(message):
|
|
1834
2554
|
"""Send message to hcom"""
|
|
1835
2555
|
# Check if hcom files exist
|
|
1836
|
-
log_file =
|
|
1837
|
-
|
|
2556
|
+
log_file = hcom_path(LOG_FILE)
|
|
2557
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1838
2558
|
|
|
1839
|
-
if not log_file.exists() and not
|
|
2559
|
+
if not log_file.exists() and not instances_dir.exists():
|
|
1840
2560
|
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
1841
2561
|
return 1
|
|
1842
2562
|
|
|
1843
2563
|
# Validate message
|
|
1844
2564
|
error = validate_message(message)
|
|
1845
2565
|
if error:
|
|
1846
|
-
print(
|
|
2566
|
+
print(error, file=sys.stderr)
|
|
1847
2567
|
return 1
|
|
1848
2568
|
|
|
1849
2569
|
# Check for unmatched mentions (minimal warning)
|
|
1850
2570
|
mentions = MENTION_PATTERN.findall(message)
|
|
1851
|
-
if mentions
|
|
2571
|
+
if mentions:
|
|
1852
2572
|
try:
|
|
1853
|
-
positions =
|
|
2573
|
+
positions = load_all_positions()
|
|
1854
2574
|
all_instances = list(positions.keys())
|
|
1855
2575
|
unmatched = [m for m in mentions
|
|
1856
2576
|
if not any(name.lower().startswith(m.lower()) for name in all_instances)]
|
|
1857
2577
|
if unmatched:
|
|
1858
|
-
print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all")
|
|
2578
|
+
print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
|
|
1859
2579
|
except Exception:
|
|
1860
2580
|
pass # Don't fail on warning
|
|
1861
2581
|
|
|
@@ -1863,7 +2583,12 @@ def cmd_send(message):
|
|
|
1863
2583
|
sender_name = get_config_value('sender_name', 'bigboss')
|
|
1864
2584
|
|
|
1865
2585
|
if send_message(sender_name, message):
|
|
1866
|
-
print("Message sent")
|
|
2586
|
+
print("Message sent", file=sys.stderr)
|
|
2587
|
+
|
|
2588
|
+
# Show cli_hints if configured (non-interactive mode)
|
|
2589
|
+
if not is_interactive():
|
|
2590
|
+
show_cli_hints()
|
|
2591
|
+
|
|
1867
2592
|
return 0
|
|
1868
2593
|
else:
|
|
1869
2594
|
print(format_error("Failed to send message"), file=sys.stderr)
|
|
@@ -1875,204 +2600,513 @@ def format_hook_messages(messages, instance_name):
|
|
|
1875
2600
|
"""Format messages for hook feedback"""
|
|
1876
2601
|
if len(messages) == 1:
|
|
1877
2602
|
msg = messages[0]
|
|
1878
|
-
reason = f"{msg['from']} → {instance_name}: {msg['message']}"
|
|
2603
|
+
reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
|
|
1879
2604
|
else:
|
|
1880
|
-
parts = [f"{msg['from']}: {msg['message']}" for msg in messages]
|
|
1881
|
-
reason = f"{len(messages)} messages
|
|
1882
|
-
|
|
2605
|
+
parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
|
|
2606
|
+
reason = f"[{len(messages)} new messages] | " + " | ".join(parts)
|
|
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
|
+
|
|
2614
|
+
# Only append instance_hints to messages (first_use_text is handled separately)
|
|
1883
2615
|
instance_hints = get_config_value('instance_hints', '')
|
|
1884
2616
|
if instance_hints:
|
|
1885
|
-
reason = f"{reason} {instance_hints}"
|
|
1886
|
-
|
|
2617
|
+
reason = f"{reason} | [{instance_hints}]"
|
|
2618
|
+
|
|
1887
2619
|
return reason
|
|
1888
2620
|
|
|
1889
|
-
def
|
|
1890
|
-
"""
|
|
1891
|
-
|
|
1892
|
-
if os.
|
|
1893
|
-
|
|
1894
|
-
|
|
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
|
+
|
|
1895
2630
|
try:
|
|
1896
|
-
# Read
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
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:
|
|
1954
2957
|
messages = get_new_messages(instance_name)
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
# Both sent and received
|
|
1958
|
-
reason = f"{sent_reason} | {format_hook_messages(messages, instance_name)}"
|
|
1959
|
-
output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
|
|
1960
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
1961
|
-
sys.exit(EXIT_BLOCK)
|
|
1962
|
-
elif messages:
|
|
1963
|
-
# Just received
|
|
2958
|
+
if messages:
|
|
2959
|
+
messages = messages[:get_config_value('max_messages_per_delivery', 50)]
|
|
1964
2960
|
reason = format_hook_messages(messages, instance_name)
|
|
1965
|
-
|
|
1966
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
1967
|
-
sys.exit(EXIT_BLOCK)
|
|
2961
|
+
response_reason = f"{sent_reason} | {reason}" if sent_reason else reason
|
|
1968
2962
|
elif sent_reason:
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
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
|
|
1975
2986
|
pass
|
|
1976
|
-
|
|
1977
|
-
sys.exit(EXIT_SUCCESS)
|
|
1978
2987
|
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
1983
|
-
sys.exit(EXIT_SUCCESS)
|
|
1984
|
-
|
|
2988
|
+
parent_pid = os.getppid()
|
|
2989
|
+
start_time = time.time()
|
|
2990
|
+
|
|
1985
2991
|
try:
|
|
1986
|
-
|
|
1987
|
-
hook_data = json.load(sys.stdin)
|
|
1988
|
-
transcript_path = hook_data.get('transcript_path', '')
|
|
1989
|
-
instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
|
|
1990
|
-
conversation_uuid = get_conversation_uuid(transcript_path)
|
|
1991
|
-
|
|
1992
|
-
# Initialize instance if needed
|
|
1993
|
-
initialize_instance_in_position_file(instance_name, conversation_uuid)
|
|
1994
|
-
|
|
1995
|
-
# Update instance as waiting
|
|
1996
|
-
update_instance_position(instance_name, {
|
|
1997
|
-
'last_stop': int(time.time()),
|
|
1998
|
-
'session_id': hook_data.get('session_id', ''),
|
|
1999
|
-
'transcript_path': transcript_path,
|
|
2000
|
-
'conversation_uuid': conversation_uuid or 'unknown',
|
|
2001
|
-
'directory': str(Path.cwd())
|
|
2002
|
-
})
|
|
2003
|
-
|
|
2004
|
-
parent_pid = os.getppid()
|
|
2005
|
-
|
|
2006
|
-
# Check for first-use help
|
|
2007
|
-
check_and_show_first_use_help(instance_name)
|
|
2008
|
-
|
|
2009
|
-
# Simple polling loop with parent check
|
|
2010
|
-
timeout = get_config_value('wait_timeout', 1800)
|
|
2011
|
-
start_time = time.time()
|
|
2012
|
-
|
|
2992
|
+
loop_count = 0
|
|
2013
2993
|
while time.time() - start_time < timeout:
|
|
2014
|
-
|
|
2015
|
-
|
|
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:
|
|
2016
2999
|
sys.exit(EXIT_SUCCESS)
|
|
2017
|
-
|
|
2018
|
-
# Check for new messages
|
|
2019
|
-
messages = get_new_messages(instance_name)
|
|
2020
|
-
|
|
2021
|
-
if messages:
|
|
2022
|
-
# Deliver messages
|
|
2023
|
-
max_messages = get_config_value('max_messages_per_delivery', 50)
|
|
2024
|
-
messages_to_show = messages[:max_messages]
|
|
2025
|
-
|
|
2026
|
-
reason = format_hook_messages(messages_to_show, instance_name)
|
|
2027
|
-
output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
|
|
2028
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
2029
|
-
sys.exit(EXIT_BLOCK)
|
|
2030
|
-
|
|
2031
|
-
# Update heartbeat
|
|
2032
|
-
update_instance_position(instance_name, {
|
|
2033
|
-
'last_stop': int(time.time())
|
|
2034
|
-
})
|
|
2035
|
-
|
|
2036
|
-
time.sleep(1)
|
|
2037
|
-
|
|
2038
|
-
except Exception:
|
|
2039
|
-
pass
|
|
2040
|
-
|
|
2041
|
-
sys.exit(EXIT_SUCCESS)
|
|
2042
3000
|
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
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)
|
|
3031
|
+
sys.exit(EXIT_SUCCESS)
|
|
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
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
print(json.dumps(output))
|
|
3074
|
+
|
|
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"""
|
|
2046
3080
|
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
2047
3081
|
sys.exit(EXIT_SUCCESS)
|
|
2048
|
-
|
|
3082
|
+
|
|
2049
3083
|
try:
|
|
2050
|
-
# Read hook input
|
|
2051
3084
|
hook_data = json.load(sys.stdin)
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
'
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
'
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
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
|
+
|
|
2071
3104
|
except Exception:
|
|
2072
3105
|
pass
|
|
2073
|
-
|
|
3106
|
+
|
|
2074
3107
|
sys.exit(EXIT_SUCCESS)
|
|
2075
3108
|
|
|
3109
|
+
|
|
2076
3110
|
# ==================== Main Entry Point ====================
|
|
2077
3111
|
|
|
2078
3112
|
def main(argv=None):
|
|
@@ -2101,18 +3135,13 @@ def main(argv=None):
|
|
|
2101
3135
|
print(format_error("Message required"), file=sys.stderr)
|
|
2102
3136
|
return 1
|
|
2103
3137
|
return cmd_send(argv[2])
|
|
3138
|
+
elif cmd == 'kill':
|
|
3139
|
+
return cmd_kill(*argv[2:])
|
|
2104
3140
|
|
|
2105
3141
|
# Hook commands
|
|
2106
|
-
elif cmd
|
|
2107
|
-
|
|
3142
|
+
elif cmd in ['post', 'stop', 'notify', 'pre', 'sessionstart']:
|
|
3143
|
+
handle_hook(cmd)
|
|
2108
3144
|
return 0
|
|
2109
|
-
elif cmd == 'stop':
|
|
2110
|
-
handle_hook_stop()
|
|
2111
|
-
return 0
|
|
2112
|
-
elif cmd == 'notify':
|
|
2113
|
-
handle_hook_notification()
|
|
2114
|
-
return 0
|
|
2115
|
-
|
|
2116
3145
|
|
|
2117
3146
|
# Unknown command
|
|
2118
3147
|
else:
|