hcom 0.2.2__py3-none-any.whl → 0.3.0__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 +754 -840
- {hcom-0.2.2.dist-info → hcom-0.3.0.dist-info}/METADATA +11 -11
- hcom-0.3.0.dist-info/RECORD +7 -0
- hcom-0.2.2.dist-info/RECORD +0 -7
- {hcom-0.2.2.dist-info → hcom-0.3.0.dist-info}/WHEEL +0 -0
- {hcom-0.2.2.dist-info → hcom-0.3.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.2.2.dist-info → hcom-0.3.0.dist-info}/top_level.txt +0 -0
hcom/__main__.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
hcom 0.
|
|
3
|
+
hcom 0.3.0
|
|
4
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
|
|
8
8
|
import sys
|
|
9
9
|
import json
|
|
10
|
+
import io
|
|
10
11
|
import tempfile
|
|
11
12
|
import shutil
|
|
12
13
|
import shlex
|
|
@@ -18,6 +19,11 @@ import platform
|
|
|
18
19
|
import random
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from datetime import datetime, timedelta
|
|
22
|
+
from typing import Optional, Any, NamedTuple
|
|
23
|
+
from dataclasses import dataclass, asdict, field
|
|
24
|
+
|
|
25
|
+
if sys.version_info < (3, 10):
|
|
26
|
+
sys.exit("Error: hcom requires Python 3.10 or higher")
|
|
21
27
|
|
|
22
28
|
# ==================== Constants ====================
|
|
23
29
|
|
|
@@ -30,7 +36,7 @@ def is_wsl():
|
|
|
30
36
|
try:
|
|
31
37
|
with open('/proc/version', 'r') as f:
|
|
32
38
|
return 'microsoft' in f.read().lower()
|
|
33
|
-
except:
|
|
39
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
34
40
|
return False
|
|
35
41
|
|
|
36
42
|
def is_termux():
|
|
@@ -38,7 +44,7 @@ def is_termux():
|
|
|
38
44
|
return (
|
|
39
45
|
'TERMUX_VERSION' in os.environ or # Primary: Works all versions
|
|
40
46
|
'TERMUX__ROOTFS' in os.environ or # Modern: v0.119.0+
|
|
41
|
-
|
|
47
|
+
Path('/data/data/com.termux').exists() or # Fallback: Path check
|
|
42
48
|
'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
|
|
43
49
|
)
|
|
44
50
|
|
|
@@ -46,25 +52,21 @@ HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
|
|
|
46
52
|
HCOM_ACTIVE_VALUE = '1'
|
|
47
53
|
|
|
48
54
|
EXIT_SUCCESS = 0
|
|
49
|
-
EXIT_ERROR = 1
|
|
50
55
|
EXIT_BLOCK = 2
|
|
51
56
|
|
|
52
|
-
HOOK_DECISION_BLOCK = 'block'
|
|
53
|
-
|
|
54
57
|
ERROR_ACCESS_DENIED = 5 # Windows - Process exists but no permission
|
|
55
58
|
ERROR_INVALID_PARAMETER = 87 # Windows - Invalid PID or parameters
|
|
56
|
-
ERROR_ALREADY_EXISTS = 183 # Windows - For file/mutex creation, not process checks
|
|
57
59
|
|
|
58
60
|
# Windows API constants
|
|
59
|
-
DETACHED_PROCESS = 0x00000008 # CreateProcess flag for no console window
|
|
60
61
|
CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
|
|
61
62
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 # Vista+ minimal access rights
|
|
62
|
-
PROCESS_QUERY_INFORMATION = 0x0400 # Pre-Vista process access rights TODO: is this a joke? why am i supporting pre vista? who the fuck is running claude code on vista let alone pre?!
|
|
63
|
+
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?! great to keep this comment here! and i will be leaving it here!
|
|
63
64
|
|
|
64
65
|
# Timing constants
|
|
65
66
|
FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
|
|
66
|
-
STOP_HOOK_POLL_INTERVAL = 0.
|
|
67
|
+
STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
|
|
67
68
|
KILL_CHECK_INTERVAL = 0.1 # 100ms between process termination checks
|
|
69
|
+
MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
|
|
68
70
|
|
|
69
71
|
# Windows kernel32 cache
|
|
70
72
|
_windows_kernel32_cache = None
|
|
@@ -77,7 +79,7 @@ def get_windows_kernel32():
|
|
|
77
79
|
if _windows_kernel32_cache is None and IS_WINDOWS:
|
|
78
80
|
import ctypes
|
|
79
81
|
import ctypes.wintypes
|
|
80
|
-
kernel32 = ctypes.windll.kernel32
|
|
82
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
81
83
|
|
|
82
84
|
# Set proper ctypes function signatures to avoid ERROR_INVALID_PARAMETER
|
|
83
85
|
kernel32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
|
|
@@ -93,6 +95,7 @@ def get_windows_kernel32():
|
|
|
93
95
|
return _windows_kernel32_cache
|
|
94
96
|
|
|
95
97
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
98
|
+
AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
|
|
96
99
|
TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
|
|
97
100
|
|
|
98
101
|
RESET = "\033[0m"
|
|
@@ -107,25 +110,38 @@ BG_GREEN = "\033[42m"
|
|
|
107
110
|
BG_CYAN = "\033[46m"
|
|
108
111
|
BG_YELLOW = "\033[43m"
|
|
109
112
|
BG_RED = "\033[41m"
|
|
113
|
+
BG_GRAY = "\033[100m"
|
|
110
114
|
|
|
111
115
|
STATUS_MAP = {
|
|
112
|
-
"thinking": (BG_CYAN, "◉"),
|
|
113
|
-
"responding": (BG_GREEN, "▷"),
|
|
114
|
-
"executing": (BG_GREEN, "▶"),
|
|
115
116
|
"waiting": (BG_BLUE, "◉"),
|
|
117
|
+
"delivered": (BG_CYAN, "▷"),
|
|
118
|
+
"active": (BG_GREEN, "▶"),
|
|
116
119
|
"blocked": (BG_YELLOW, "■"),
|
|
117
|
-
"inactive": (BG_RED, "○")
|
|
120
|
+
"inactive": (BG_RED, "○"),
|
|
121
|
+
"unknown": (BG_GRAY, "○")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Map status events to (display_category, description_template)
|
|
125
|
+
STATUS_INFO = {
|
|
126
|
+
'session_start': ('active', 'started'),
|
|
127
|
+
'tool_pending': ('active', '{} executing'),
|
|
128
|
+
'waiting': ('waiting', 'idle'),
|
|
129
|
+
'message_delivered': ('delivered', 'msg from {}'),
|
|
130
|
+
'stop_exit': ('inactive', 'stopped'),
|
|
131
|
+
'timeout': ('inactive', 'timeout'),
|
|
132
|
+
'killed': ('inactive', 'killed'),
|
|
133
|
+
'blocked': ('blocked', '{} blocked'),
|
|
134
|
+
'unknown': ('unknown', 'unknown'),
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
# ==================== Windows/WSL Console Unicode ====================
|
|
121
|
-
import io
|
|
122
138
|
|
|
123
139
|
# Apply UTF-8 encoding for Windows and WSL
|
|
124
140
|
if IS_WINDOWS or is_wsl():
|
|
125
141
|
try:
|
|
126
142
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
127
143
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
|
128
|
-
except:
|
|
144
|
+
except (AttributeError, OSError):
|
|
129
145
|
pass # Fallback if stream redirection fails
|
|
130
146
|
|
|
131
147
|
# ==================== Error Handling Strategy ====================
|
|
@@ -134,39 +150,51 @@ if IS_WINDOWS or is_wsl():
|
|
|
134
150
|
# Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
|
|
135
151
|
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
136
152
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
def log_hook_error(hook_name: str, error: Exception | None = None):
|
|
154
|
+
"""Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
|
|
155
|
+
import traceback
|
|
156
|
+
try:
|
|
157
|
+
log_file = hcom_path(SCRIPTS_DIR) / "hooks.log"
|
|
158
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
timestamp = datetime.now().isoformat()
|
|
160
|
+
if error and isinstance(error, Exception):
|
|
161
|
+
tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
162
|
+
with open(log_file, 'a') as f:
|
|
163
|
+
f.write(f"{timestamp}|{hook_name}|{type(error).__name__}: {error}\n{tb}\n")
|
|
164
|
+
else:
|
|
165
|
+
with open(log_file, 'a') as f:
|
|
166
|
+
f.write(f"{timestamp}|{hook_name}|{error or 'checkpoint'}\n")
|
|
167
|
+
except (OSError, PermissionError):
|
|
168
|
+
pass # Silent failure in error logging
|
|
169
|
+
|
|
170
|
+
# ==================== Config Defaults ====================
|
|
171
|
+
|
|
172
|
+
# Type definition for configuration
|
|
173
|
+
@dataclass
|
|
174
|
+
class HcomConfig:
|
|
175
|
+
terminal_command: str | None = None
|
|
176
|
+
terminal_mode: str = "new_window"
|
|
177
|
+
initial_prompt: str = "Say hi in chat"
|
|
178
|
+
sender_name: str = "bigboss"
|
|
179
|
+
sender_emoji: str = "🐳"
|
|
180
|
+
cli_hints: str = ""
|
|
181
|
+
wait_timeout: int = 1800 # 30mins
|
|
182
|
+
max_message_size: int = 1048576 # 1MB
|
|
183
|
+
max_messages_per_delivery: int = 50
|
|
184
|
+
first_use_text: str = "Essential, concise messages only, say hi in hcom chat now"
|
|
185
|
+
instance_hints: str = ""
|
|
186
|
+
env_overrides: dict = field(default_factory=dict)
|
|
187
|
+
auto_watch: bool = True # Auto-launch watch dashboard after open
|
|
188
|
+
|
|
189
|
+
DEFAULT_CONFIG = HcomConfig()
|
|
154
190
|
|
|
155
191
|
_config = None
|
|
156
192
|
|
|
193
|
+
# Generate env var mappings from dataclass fields (except env_overrides)
|
|
157
194
|
HOOK_SETTINGS = {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
'first_use_text': 'HCOM_FIRST_USE_TEXT',
|
|
162
|
-
'instance_hints': 'HCOM_INSTANCE_HINTS',
|
|
163
|
-
'sender_name': 'HCOM_SENDER_NAME',
|
|
164
|
-
'sender_emoji': 'HCOM_SENDER_EMOJI',
|
|
165
|
-
'cli_hints': 'HCOM_CLI_HINTS',
|
|
166
|
-
'terminal_mode': 'HCOM_TERMINAL_MODE',
|
|
167
|
-
'terminal_command': 'HCOM_TERMINAL_COMMAND',
|
|
168
|
-
'initial_prompt': 'HCOM_INITIAL_PROMPT',
|
|
169
|
-
'auto_watch': 'HCOM_AUTO_WATCH'
|
|
195
|
+
field: f"HCOM_{field.upper()}"
|
|
196
|
+
for field in DEFAULT_CONFIG.__dataclass_fields__
|
|
197
|
+
if field != 'env_overrides'
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
# Path constants
|
|
@@ -179,7 +207,7 @@ ARCHIVE_DIR = "archive"
|
|
|
179
207
|
|
|
180
208
|
# ==================== File System Utilities ====================
|
|
181
209
|
|
|
182
|
-
def hcom_path(*parts, ensure_parent=False):
|
|
210
|
+
def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
|
|
183
211
|
"""Build path under ~/.hcom"""
|
|
184
212
|
path = Path.home() / ".hcom"
|
|
185
213
|
if parts:
|
|
@@ -188,8 +216,8 @@ def hcom_path(*parts, ensure_parent=False):
|
|
|
188
216
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
217
|
return path
|
|
190
218
|
|
|
191
|
-
def atomic_write(filepath, content):
|
|
192
|
-
"""Write content to file atomically to prevent corruption (now with NEW and IMPROVED (wow!) Windows retry logic (cool
|
|
219
|
+
def atomic_write(filepath: str | Path, content: str) -> bool:
|
|
220
|
+
"""Write content to file atomically to prevent corruption (now with NEW and IMPROVED (wow!) Windows retry logic (cool!!!)). Returns True on success, False on failure."""
|
|
193
221
|
filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
|
|
194
222
|
|
|
195
223
|
for attempt in range(3):
|
|
@@ -207,18 +235,20 @@ def atomic_write(filepath, content):
|
|
|
207
235
|
continue
|
|
208
236
|
else:
|
|
209
237
|
try: # Clean up temp file on final failure
|
|
210
|
-
|
|
211
|
-
except:
|
|
238
|
+
Path(tmp.name).unlink()
|
|
239
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
212
240
|
pass
|
|
213
241
|
return False
|
|
214
242
|
except Exception:
|
|
215
243
|
try: # Clean up temp file on any other error
|
|
216
244
|
os.unlink(tmp.name)
|
|
217
|
-
except:
|
|
245
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
218
246
|
pass
|
|
219
247
|
return False
|
|
220
248
|
|
|
221
|
-
|
|
249
|
+
return False # All attempts exhausted
|
|
250
|
+
|
|
251
|
+
def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, max_retries: int = 3) -> Any:
|
|
222
252
|
"""Read file with retry logic for Windows file locking"""
|
|
223
253
|
if not Path(filepath).exists():
|
|
224
254
|
return default
|
|
@@ -227,7 +257,7 @@ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
|
|
|
227
257
|
try:
|
|
228
258
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
229
259
|
return read_func(f)
|
|
230
|
-
except PermissionError
|
|
260
|
+
except PermissionError:
|
|
231
261
|
# Only retry on Windows (file locking issue)
|
|
232
262
|
if IS_WINDOWS and attempt < max_retries - 1:
|
|
233
263
|
time.sleep(FILE_RETRY_DELAY)
|
|
@@ -241,30 +271,18 @@ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
|
|
|
241
271
|
|
|
242
272
|
return default
|
|
243
273
|
|
|
244
|
-
def get_instance_file(instance_name):
|
|
245
|
-
"""Get path to instance's position file"""
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# Convert single session_id to session_ids array
|
|
253
|
-
if 'session_ids' not in data and 'session_id' in data and data['session_id']:
|
|
254
|
-
data['session_ids'] = [data['session_id']]
|
|
255
|
-
needs_save = True
|
|
256
|
-
|
|
257
|
-
# Remove conversation_uuid - no longer used anywhere
|
|
258
|
-
if 'conversation_uuid' in data:
|
|
259
|
-
del data['conversation_uuid']
|
|
260
|
-
needs_save = True
|
|
261
|
-
|
|
262
|
-
if needs_save:
|
|
263
|
-
save_instance_position(instance_name, data)
|
|
274
|
+
def get_instance_file(instance_name: str) -> Path:
|
|
275
|
+
"""Get path to instance's position file with path traversal protection"""
|
|
276
|
+
# Sanitize instance name to prevent directory traversal
|
|
277
|
+
if not instance_name:
|
|
278
|
+
instance_name = "unknown"
|
|
279
|
+
safe_name = instance_name.replace('..', '').replace('/', '-').replace('\\', '-').replace(os.sep, '-')
|
|
280
|
+
if not safe_name:
|
|
281
|
+
safe_name = "unknown"
|
|
264
282
|
|
|
265
|
-
return
|
|
283
|
+
return hcom_path(INSTANCES_DIR, f"{safe_name}.json")
|
|
266
284
|
|
|
267
|
-
def load_instance_position(instance_name):
|
|
285
|
+
def load_instance_position(instance_name: str) -> dict[str, Any]:
|
|
268
286
|
"""Load position data for a single instance"""
|
|
269
287
|
instance_file = get_instance_file(instance_name)
|
|
270
288
|
|
|
@@ -274,21 +292,17 @@ def load_instance_position(instance_name):
|
|
|
274
292
|
default={}
|
|
275
293
|
)
|
|
276
294
|
|
|
277
|
-
# Apply migration if needed
|
|
278
|
-
if data:
|
|
279
|
-
data = migrate_instance_data_v020(data, instance_name)
|
|
280
|
-
|
|
281
295
|
return data
|
|
282
296
|
|
|
283
|
-
def save_instance_position(instance_name, data):
|
|
297
|
+
def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
|
|
284
298
|
"""Save position data for a single instance. Returns True on success, False on failure."""
|
|
285
299
|
try:
|
|
286
300
|
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json", ensure_parent=True)
|
|
287
301
|
return atomic_write(instance_file, json.dumps(data, indent=2))
|
|
288
|
-
except:
|
|
302
|
+
except (OSError, PermissionError, ValueError):
|
|
289
303
|
return False
|
|
290
304
|
|
|
291
|
-
def load_all_positions():
|
|
305
|
+
def load_all_positions() -> dict[str, dict[str, Any]]:
|
|
292
306
|
"""Load positions from all instance files"""
|
|
293
307
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
294
308
|
if not instances_dir.exists():
|
|
@@ -306,31 +320,18 @@ def load_all_positions():
|
|
|
306
320
|
positions[instance_name] = data
|
|
307
321
|
return positions
|
|
308
322
|
|
|
309
|
-
def clear_all_positions():
|
|
310
|
-
"""Clear all instance position files"""
|
|
323
|
+
def clear_all_positions() -> None:
|
|
324
|
+
"""Clear all instance position files and related mapping files"""
|
|
311
325
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
312
326
|
if instances_dir.exists():
|
|
313
327
|
for f in instances_dir.glob('*.json'):
|
|
314
328
|
f.unlink()
|
|
329
|
+
# Clean up orphaned mapping files
|
|
330
|
+
for f in instances_dir.glob('.launch_map_*'):
|
|
331
|
+
f.unlink(missing_ok=True)
|
|
315
332
|
else:
|
|
316
333
|
instances_dir.mkdir(exist_ok=True)
|
|
317
334
|
|
|
318
|
-
# ==================== Hook Utilities ====================
|
|
319
|
-
|
|
320
|
-
HCOM_FORMAT_INSTRUCTIONS = """\n\nREQUIRED: Display HCOM 'reason' content using this format with markdown:
|
|
321
|
-
═════════ HCOM ═════════
|
|
322
|
-
***[✓ Sent] [new message] and/or [all other info in square brackets]***
|
|
323
|
-
**name → name:** *message content*
|
|
324
|
-
════════════════════════
|
|
325
|
-
Always show exact full content of every single message without saying why you are displaying it. This format is mandatory for system functionality"""
|
|
326
|
-
|
|
327
|
-
def emit_hook_response(reason, decision=HOOK_DECISION_BLOCK, exit_code=EXIT_BLOCK):
|
|
328
|
-
"""Emit standardized hook JSON response"""
|
|
329
|
-
output = {"decision": decision, "reason": reason} if decision else {"reason": reason}
|
|
330
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
331
|
-
sys.exit(exit_code)
|
|
332
|
-
|
|
333
|
-
|
|
334
335
|
# ==================== Configuration System ====================
|
|
335
336
|
|
|
336
337
|
def get_cached_config():
|
|
@@ -340,33 +341,31 @@ def get_cached_config():
|
|
|
340
341
|
_config = _load_config_from_file()
|
|
341
342
|
return _config
|
|
342
343
|
|
|
343
|
-
def _load_config_from_file():
|
|
344
|
-
"""
|
|
345
|
-
import copy
|
|
344
|
+
def _load_config_from_file() -> dict:
|
|
345
|
+
"""Load configuration from ~/.hcom/config.json"""
|
|
346
346
|
config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
|
|
347
347
|
|
|
348
|
-
config
|
|
348
|
+
# Start with default config as dict
|
|
349
|
+
config_dict = asdict(DEFAULT_CONFIG)
|
|
349
350
|
|
|
350
351
|
try:
|
|
351
|
-
user_config
|
|
352
|
+
if user_config := read_file_with_retry(
|
|
352
353
|
config_path,
|
|
353
354
|
lambda f: json.load(f),
|
|
354
355
|
default=None
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if key == 'env_overrides':
|
|
359
|
-
config['env_overrides'].update(value)
|
|
360
|
-
else:
|
|
361
|
-
config[key] = value
|
|
356
|
+
):
|
|
357
|
+
# Merge user config into default config
|
|
358
|
+
config_dict.update(user_config)
|
|
362
359
|
elif not config_path.exists():
|
|
363
|
-
|
|
360
|
+
# Write default config if file doesn't exist
|
|
361
|
+
atomic_write(config_path, json.dumps(config_dict, indent=2))
|
|
364
362
|
except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
|
|
365
363
|
print("Warning: Cannot read config file, using defaults", file=sys.stderr)
|
|
364
|
+
# config_dict already has defaults
|
|
366
365
|
|
|
367
|
-
return
|
|
366
|
+
return config_dict
|
|
368
367
|
|
|
369
|
-
def get_config_value(key, default=None):
|
|
368
|
+
def get_config_value(key: str, default: Any = None) -> Any:
|
|
370
369
|
"""Get config value with proper precedence:
|
|
371
370
|
1. Environment variable (if in HOOK_SETTINGS)
|
|
372
371
|
2. Config file
|
|
@@ -374,16 +373,18 @@ def get_config_value(key, default=None):
|
|
|
374
373
|
"""
|
|
375
374
|
if key in HOOK_SETTINGS:
|
|
376
375
|
env_var = HOOK_SETTINGS[key]
|
|
377
|
-
env_value
|
|
378
|
-
|
|
376
|
+
if (env_value := os.environ.get(env_var)) is not None:
|
|
377
|
+
# Type conversion based on key
|
|
379
378
|
if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
|
|
380
379
|
try:
|
|
381
380
|
return int(env_value)
|
|
382
381
|
except ValueError:
|
|
382
|
+
# Invalid integer - fall through to config/default
|
|
383
383
|
pass
|
|
384
|
-
elif key == 'auto_watch':
|
|
384
|
+
elif key == 'auto_watch':
|
|
385
385
|
return env_value.lower() in ('true', '1', 'yes', 'on')
|
|
386
386
|
else:
|
|
387
|
+
# String values - return as-is
|
|
387
388
|
return env_value
|
|
388
389
|
|
|
389
390
|
config = get_cached_config()
|
|
@@ -396,7 +397,7 @@ def get_hook_command():
|
|
|
396
397
|
Both approaches exit silently (code 0) when not launched via 'hcom open'.
|
|
397
398
|
"""
|
|
398
399
|
python_path = sys.executable
|
|
399
|
-
script_path =
|
|
400
|
+
script_path = str(Path(__file__).resolve())
|
|
400
401
|
|
|
401
402
|
if IS_WINDOWS:
|
|
402
403
|
# Windows cmd.exe syntax - no parentheses so arguments append correctly
|
|
@@ -412,6 +413,15 @@ def get_hook_command():
|
|
|
412
413
|
# Unix clean paths: use environment variable
|
|
413
414
|
return '${HCOM:-true}', {}
|
|
414
415
|
|
|
416
|
+
def build_send_command(example_msg: str = '') -> str:
|
|
417
|
+
"""Build send command string - checks if $HCOM exists, falls back to full path"""
|
|
418
|
+
msg = f" '{example_msg}'" if example_msg else ''
|
|
419
|
+
if os.environ.get('HCOM'):
|
|
420
|
+
return f'eval "$HCOM send{msg}"'
|
|
421
|
+
python_path = shlex.quote(sys.executable)
|
|
422
|
+
script_path = shlex.quote(str(Path(__file__).resolve()))
|
|
423
|
+
return f'{python_path} {script_path} send{msg}'
|
|
424
|
+
|
|
415
425
|
def build_claude_env():
|
|
416
426
|
"""Build environment variables for Claude instances"""
|
|
417
427
|
env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
|
|
@@ -433,7 +443,7 @@ def build_claude_env():
|
|
|
433
443
|
|
|
434
444
|
# Set HCOM only for clean paths (spaces handled differently)
|
|
435
445
|
python_path = sys.executable
|
|
436
|
-
script_path =
|
|
446
|
+
script_path = str(Path(__file__).resolve())
|
|
437
447
|
if ' ' not in python_path and ' ' not in script_path:
|
|
438
448
|
env['HCOM'] = f'{python_path} {script_path}'
|
|
439
449
|
|
|
@@ -441,8 +451,8 @@ def build_claude_env():
|
|
|
441
451
|
|
|
442
452
|
# ==================== Message System ====================
|
|
443
453
|
|
|
444
|
-
def validate_message(message):
|
|
445
|
-
"""Validate message size and content"""
|
|
454
|
+
def validate_message(message: str) -> Optional[str]:
|
|
455
|
+
"""Validate message size and content. Returns error message or None if valid."""
|
|
446
456
|
if not message or not message.strip():
|
|
447
457
|
return format_error("Message required")
|
|
448
458
|
|
|
@@ -456,7 +466,7 @@ def validate_message(message):
|
|
|
456
466
|
|
|
457
467
|
return None
|
|
458
468
|
|
|
459
|
-
def send_message(from_instance, message):
|
|
469
|
+
def send_message(from_instance: str, message: str) -> bool:
|
|
460
470
|
"""Send a message to the log"""
|
|
461
471
|
try:
|
|
462
472
|
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
@@ -475,7 +485,7 @@ def send_message(from_instance, message):
|
|
|
475
485
|
except Exception:
|
|
476
486
|
return False
|
|
477
487
|
|
|
478
|
-
def should_deliver_message(msg, instance_name, all_instance_names=None):
|
|
488
|
+
def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: Optional[list[str]] = None) -> bool:
|
|
479
489
|
"""Check if message should be delivered based on @-mentions"""
|
|
480
490
|
text = msg['message']
|
|
481
491
|
|
|
@@ -509,9 +519,9 @@ def should_deliver_message(msg, instance_name, all_instance_names=None):
|
|
|
509
519
|
|
|
510
520
|
return False # This instance doesn't match, but others might
|
|
511
521
|
|
|
512
|
-
# ==================== Parsing
|
|
522
|
+
# ==================== Parsing & Utilities ====================
|
|
513
523
|
|
|
514
|
-
def parse_open_args(args):
|
|
524
|
+
def parse_open_args(args: list[str]) -> tuple[list[str], Optional[str], list[str], bool]:
|
|
515
525
|
"""Parse arguments for open command
|
|
516
526
|
|
|
517
527
|
Returns:
|
|
@@ -566,7 +576,7 @@ def parse_open_args(args):
|
|
|
566
576
|
|
|
567
577
|
return instances, prefix, claude_args, background
|
|
568
578
|
|
|
569
|
-
def extract_agent_config(content):
|
|
579
|
+
def extract_agent_config(content: str) -> dict[str, str]:
|
|
570
580
|
"""Extract configuration from agent YAML frontmatter"""
|
|
571
581
|
if not content.startswith('---'):
|
|
572
582
|
return {}
|
|
@@ -580,49 +590,89 @@ def extract_agent_config(content):
|
|
|
580
590
|
config = {}
|
|
581
591
|
|
|
582
592
|
# Extract model field
|
|
583
|
-
model_match
|
|
584
|
-
if model_match:
|
|
593
|
+
if model_match := re.search(r'^model:\s*(.+)$', yaml_section, re.MULTILINE):
|
|
585
594
|
value = model_match.group(1).strip()
|
|
586
595
|
if value and value.lower() != 'inherit':
|
|
587
596
|
config['model'] = value
|
|
588
|
-
|
|
597
|
+
|
|
589
598
|
# Extract tools field
|
|
590
|
-
tools_match
|
|
591
|
-
if tools_match:
|
|
599
|
+
if tools_match := re.search(r'^tools:\s*(.+)$', yaml_section, re.MULTILINE):
|
|
592
600
|
value = tools_match.group(1).strip()
|
|
593
601
|
if value:
|
|
594
602
|
config['tools'] = value.replace(', ', ',')
|
|
595
603
|
|
|
596
604
|
return config
|
|
597
605
|
|
|
598
|
-
def resolve_agent(name):
|
|
599
|
-
"""Resolve agent file by name
|
|
600
|
-
|
|
606
|
+
def resolve_agent(name: str) -> tuple[str, dict[str, str]]:
|
|
607
|
+
"""Resolve agent file by name with validation.
|
|
608
|
+
|
|
601
609
|
Looks for agent files in:
|
|
602
610
|
1. .claude/agents/{name}.md (local)
|
|
603
611
|
2. ~/.claude/agents/{name}.md (global)
|
|
604
|
-
|
|
605
|
-
Returns tuple: (content
|
|
612
|
+
|
|
613
|
+
Returns tuple: (content without YAML frontmatter, config dict)
|
|
606
614
|
"""
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
615
|
+
hint = 'Agent names must use lowercase letters and dashes only'
|
|
616
|
+
|
|
617
|
+
if not isinstance(name, str):
|
|
618
|
+
raise FileNotFoundError(format_error(
|
|
619
|
+
f"Agent '{name}' not found",
|
|
620
|
+
hint
|
|
621
|
+
))
|
|
622
|
+
|
|
623
|
+
candidate = name.strip()
|
|
624
|
+
display_name = candidate or name
|
|
625
|
+
|
|
626
|
+
if not candidate or not AGENT_NAME_PATTERN.fullmatch(candidate):
|
|
627
|
+
raise FileNotFoundError(format_error(
|
|
628
|
+
f"Agent '{display_name}' not found",
|
|
629
|
+
hint
|
|
630
|
+
))
|
|
631
|
+
|
|
632
|
+
for base_path in (Path.cwd(), Path.home()):
|
|
633
|
+
agents_dir = base_path / '.claude' / 'agents'
|
|
634
|
+
try:
|
|
635
|
+
agents_dir_resolved = agents_dir.resolve(strict=True)
|
|
636
|
+
except FileNotFoundError:
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
agent_path = agents_dir / f'{candidate}.md'
|
|
640
|
+
if not agent_path.exists():
|
|
641
|
+
continue
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
resolved_agent_path = agent_path.resolve(strict=True)
|
|
645
|
+
except FileNotFoundError:
|
|
646
|
+
continue
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
resolved_agent_path.relative_to(agents_dir_resolved)
|
|
650
|
+
except ValueError:
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
content = read_file_with_retry(
|
|
654
|
+
agent_path,
|
|
655
|
+
lambda f: f.read(),
|
|
656
|
+
default=None
|
|
657
|
+
)
|
|
658
|
+
if content is None:
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
config = extract_agent_config(content)
|
|
662
|
+
stripped = strip_frontmatter(content)
|
|
663
|
+
if not stripped.strip():
|
|
664
|
+
raise ValueError(format_error(
|
|
665
|
+
f"Agent '{candidate}' has empty content",
|
|
666
|
+
'Check the agent file is a valid format and contains text'
|
|
667
|
+
))
|
|
668
|
+
return stripped, config
|
|
669
|
+
|
|
670
|
+
raise FileNotFoundError(format_error(
|
|
671
|
+
f"Agent '{candidate}' not found in project or user .claude/agents/ folder",
|
|
672
|
+
'Check available agents or create the agent file'
|
|
673
|
+
))
|
|
674
|
+
|
|
675
|
+
def strip_frontmatter(content: str) -> str:
|
|
626
676
|
"""Strip YAML frontmatter from agent file"""
|
|
627
677
|
if content.startswith('---'):
|
|
628
678
|
# Find the closing --- on its own line
|
|
@@ -632,7 +682,7 @@ def strip_frontmatter(content):
|
|
|
632
682
|
return '\n'.join(lines[i+1:]).strip()
|
|
633
683
|
return content
|
|
634
684
|
|
|
635
|
-
def get_display_name(session_id, prefix=None):
|
|
685
|
+
def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) -> str:
|
|
636
686
|
"""Get display name for instance using session_id"""
|
|
637
687
|
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']
|
|
638
688
|
# Phonetic letters (5 per syllable, matches syls order)
|
|
@@ -654,24 +704,27 @@ def get_display_name(session_id, prefix=None):
|
|
|
654
704
|
hex_char = session_id[0] if session_id else 'x'
|
|
655
705
|
base_name = f"{dir_char}{syllable}{letter}{hex_char}"
|
|
656
706
|
|
|
657
|
-
# Collision detection: if taken by
|
|
707
|
+
# Collision detection: if taken by different session_id, use more chars
|
|
658
708
|
instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
|
|
659
709
|
if instance_file.exists():
|
|
660
710
|
try:
|
|
661
711
|
with open(instance_file, 'r', encoding='utf-8') as f:
|
|
662
712
|
data = json.load(f)
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
713
|
+
|
|
714
|
+
their_session_id = data.get('session_id', '')
|
|
715
|
+
|
|
716
|
+
# Deterministic check: different session_id = collision
|
|
717
|
+
if their_session_id and their_session_id != session_id:
|
|
667
718
|
# Use first 4 chars of session_id for collision resolution
|
|
668
719
|
base_name = f"{dir_char}{session_id[0:4]}"
|
|
669
|
-
|
|
670
|
-
|
|
720
|
+
# If same session_id, it's our file - reuse the name (no collision)
|
|
721
|
+
# If no session_id in file, assume it's stale/malformed - use base name
|
|
722
|
+
|
|
723
|
+
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
|
724
|
+
pass # Malformed file - assume stale, use base name
|
|
671
725
|
else:
|
|
672
|
-
#
|
|
673
|
-
|
|
674
|
-
base_name = f"{dir_char}{pid_suffix}claude"
|
|
726
|
+
# session_id is required - fail gracefully
|
|
727
|
+
raise ValueError("session_id required for instance naming")
|
|
675
728
|
|
|
676
729
|
if prefix:
|
|
677
730
|
return f"{prefix}-{base_name}"
|
|
@@ -688,31 +741,27 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
688
741
|
import copy
|
|
689
742
|
|
|
690
743
|
# Patterns to match any hcom hook command
|
|
691
|
-
# -
|
|
692
|
-
# - ${HCOM
|
|
744
|
+
# Modern hooks (patterns 1-2, 7): Match all hook types via env var or wrapper
|
|
745
|
+
# - ${HCOM:-true} sessionstart/pre/stop/notify/userpromptsubmit
|
|
693
746
|
# - [ "${HCOM_ACTIVE}" = "1" ] && ... hcom.py ... || true
|
|
694
|
-
# - hcom post/stop/notify
|
|
695
|
-
# - uvx hcom post/stop/notify
|
|
696
|
-
# - /path/to/hcom.py post/stop/notify
|
|
697
747
|
# - sh -c "[ ... ] && ... hcom ..."
|
|
698
|
-
#
|
|
699
|
-
# -
|
|
700
|
-
#
|
|
701
|
-
#
|
|
702
|
-
# The (post|stop|notify) patterns (3-6) are for older direct command formats that didn't
|
|
703
|
-
# include pre/sessionstart hooks.
|
|
748
|
+
# Legacy hooks (patterns 3-6): Direct command invocation
|
|
749
|
+
# - hcom pre/post/stop/notify/sessionstart/userpromptsubmit
|
|
750
|
+
# - uvx hcom pre/post/stop/notify/sessionstart/userpromptsubmit
|
|
751
|
+
# - /path/to/hcom.py pre/post/stop/notify/sessionstart/userpromptsubmit
|
|
704
752
|
hcom_patterns = [
|
|
705
753
|
r'\$\{?HCOM', # Environment variable (${HCOM:-true}) - all hook types
|
|
706
754
|
r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with full path - all hook types
|
|
707
|
-
r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command
|
|
708
|
-
r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command
|
|
709
|
-
r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote
|
|
710
|
-
r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b(?=\s|$)', # Quoted path
|
|
755
|
+
r'\bhcom\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # Direct hcom command
|
|
756
|
+
r'\buvx\s+hcom\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # uvx hcom command
|
|
757
|
+
r'hcom\.py["\']?\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # hcom.py with optional quote
|
|
758
|
+
r'["\'][^"\']*hcom\.py["\']?\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b(?=\s|$)', # Quoted path
|
|
711
759
|
r'sh\s+-c.*hcom', # Shell wrapper with hcom
|
|
712
760
|
]
|
|
713
761
|
compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
|
|
714
|
-
|
|
715
|
-
|
|
762
|
+
|
|
763
|
+
# Check all hook types including PostToolUse for backward compatibility cleanup
|
|
764
|
+
for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
|
|
716
765
|
if event not in settings['hooks']:
|
|
717
766
|
continue
|
|
718
767
|
|
|
@@ -758,7 +807,7 @@ def build_env_string(env_vars, format_type="bash"):
|
|
|
758
807
|
return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
|
|
759
808
|
|
|
760
809
|
|
|
761
|
-
def format_error(message, suggestion=None):
|
|
810
|
+
def format_error(message: str, suggestion: Optional[str] = None) -> str:
|
|
762
811
|
"""Format error message consistently"""
|
|
763
812
|
base = f"Error: {message}"
|
|
764
813
|
if suggestion:
|
|
@@ -773,7 +822,7 @@ def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
|
773
822
|
for arg in claude_args
|
|
774
823
|
)
|
|
775
824
|
|
|
776
|
-
def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat", model=None, tools=None):
|
|
825
|
+
def build_claude_command(agent_content: Optional[str] = None, claude_args: Optional[list[str]] = None, initial_prompt: str = "Say hi in chat", model: Optional[str] = None, tools: Optional[str] = None) -> tuple[str, Optional[str]]:
|
|
777
826
|
"""Build Claude command with proper argument handling
|
|
778
827
|
Returns tuple: (command_string, temp_file_path_or_none)
|
|
779
828
|
For agent content, writes to temp file and uses cat to read it.
|
|
@@ -850,7 +899,7 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
|
850
899
|
paths_to_add = []
|
|
851
900
|
for p in [node_path, claude_path]:
|
|
852
901
|
if p:
|
|
853
|
-
dir_path =
|
|
902
|
+
dir_path = str(Path(p).resolve().parent)
|
|
854
903
|
if dir_path not in paths_to_add:
|
|
855
904
|
paths_to_add.append(dir_path)
|
|
856
905
|
|
|
@@ -907,17 +956,17 @@ def find_bash_on_windows():
|
|
|
907
956
|
os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
|
|
908
957
|
if base:
|
|
909
958
|
candidates.extend([
|
|
910
|
-
|
|
911
|
-
|
|
959
|
+
str(Path(base) / 'Git' / 'usr' / 'bin' / 'bash.exe'), # usr/bin is more common
|
|
960
|
+
str(Path(base) / 'Git' / 'bin' / 'bash.exe')
|
|
912
961
|
])
|
|
913
962
|
|
|
914
963
|
# 2. Portable Git installation
|
|
915
964
|
local_appdata = os.environ.get('LOCALAPPDATA', '')
|
|
916
965
|
if local_appdata:
|
|
917
|
-
git_portable =
|
|
966
|
+
git_portable = Path(local_appdata) / 'Programs' / 'Git'
|
|
918
967
|
candidates.extend([
|
|
919
|
-
|
|
920
|
-
|
|
968
|
+
str(git_portable / 'usr' / 'bin' / 'bash.exe'),
|
|
969
|
+
str(git_portable / 'bin' / 'bash.exe')
|
|
921
970
|
])
|
|
922
971
|
|
|
923
972
|
# 3. PATH bash (if not WSL's launcher)
|
|
@@ -935,7 +984,7 @@ def find_bash_on_windows():
|
|
|
935
984
|
|
|
936
985
|
# Find first existing bash
|
|
937
986
|
for bash in candidates:
|
|
938
|
-
if bash and
|
|
987
|
+
if bash and Path(bash).exists():
|
|
939
988
|
return bash
|
|
940
989
|
|
|
941
990
|
return None
|
|
@@ -976,20 +1025,23 @@ def get_linux_terminal_argv():
|
|
|
976
1025
|
|
|
977
1026
|
def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
|
|
978
1027
|
"""Create hidden Windows process without console window."""
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1028
|
+
if IS_WINDOWS:
|
|
1029
|
+
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
|
1030
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
|
|
1031
|
+
startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined]
|
|
1032
|
+
|
|
1033
|
+
return subprocess.Popen(
|
|
1034
|
+
argv,
|
|
1035
|
+
env=env,
|
|
1036
|
+
cwd=cwd,
|
|
1037
|
+
stdin=subprocess.DEVNULL,
|
|
1038
|
+
stdout=stdout,
|
|
1039
|
+
stderr=subprocess.STDOUT,
|
|
1040
|
+
startupinfo=startupinfo,
|
|
1041
|
+
creationflags=CREATE_NO_WINDOW
|
|
1042
|
+
)
|
|
1043
|
+
else:
|
|
1044
|
+
raise RuntimeError("windows_hidden_popen called on non-Windows platform")
|
|
993
1045
|
|
|
994
1046
|
# Platform dispatch map
|
|
995
1047
|
PLATFORM_TERMINAL_GETTERS = {
|
|
@@ -1036,18 +1088,14 @@ def _parse_terminal_command(template, script_file):
|
|
|
1036
1088
|
|
|
1037
1089
|
return replaced
|
|
1038
1090
|
|
|
1039
|
-
def launch_terminal(command, env,
|
|
1091
|
+
def launch_terminal(command, env, cwd=None, background=False):
|
|
1040
1092
|
"""Launch terminal with command using unified script-first approach
|
|
1041
1093
|
Args:
|
|
1042
1094
|
command: Command string from build_claude_command
|
|
1043
1095
|
env: Environment variables to set
|
|
1044
|
-
config: Configuration dict
|
|
1045
1096
|
cwd: Working directory
|
|
1046
1097
|
background: Launch as background process
|
|
1047
1098
|
"""
|
|
1048
|
-
if config is None:
|
|
1049
|
-
config = get_cached_config()
|
|
1050
|
-
|
|
1051
1099
|
env_vars = os.environ.copy()
|
|
1052
1100
|
env_vars.update(env)
|
|
1053
1101
|
command_str = command
|
|
@@ -1113,7 +1161,7 @@ def launch_terminal(command, env, config=None, cwd=None, background=False):
|
|
|
1113
1161
|
script_content = f.read()
|
|
1114
1162
|
print(f"# Script: {script_file}")
|
|
1115
1163
|
print(script_content)
|
|
1116
|
-
|
|
1164
|
+
Path(script_file).unlink() # Clean up immediately
|
|
1117
1165
|
return True
|
|
1118
1166
|
except Exception as e:
|
|
1119
1167
|
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
@@ -1227,11 +1275,12 @@ def setup_hooks():
|
|
|
1227
1275
|
# Get wait_timeout (needed for Stop hook)
|
|
1228
1276
|
wait_timeout = get_config_value('wait_timeout', 1800)
|
|
1229
1277
|
|
|
1230
|
-
# Define all hooks
|
|
1278
|
+
# Define all hooks (PostToolUse removed - causes API 400 errors)
|
|
1231
1279
|
hook_configs = [
|
|
1232
1280
|
('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
|
|
1281
|
+
('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
|
|
1233
1282
|
('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
|
|
1234
|
-
('PostToolUse', '.*', f'{hook_cmd_base} post', None),
|
|
1283
|
+
# ('PostToolUse', '.*', f'{hook_cmd_base} post', None), # DISABLED
|
|
1235
1284
|
('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
|
|
1236
1285
|
('Notification', '', f'{hook_cmd_base} notify', None),
|
|
1237
1286
|
]
|
|
@@ -1275,9 +1324,9 @@ def verify_hooks_installed(settings_path):
|
|
|
1275
1324
|
if not settings:
|
|
1276
1325
|
return False
|
|
1277
1326
|
|
|
1278
|
-
# Check all hook types exist with HCOM commands
|
|
1327
|
+
# Check all hook types exist with HCOM commands (PostToolUse removed)
|
|
1279
1328
|
hooks = settings.get('hooks', {})
|
|
1280
|
-
for hook_type in ['SessionStart', 'PreToolUse', '
|
|
1329
|
+
for hook_type in ['SessionStart', 'PreToolUse', 'Stop', 'Notification']:
|
|
1281
1330
|
if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
|
|
1282
1331
|
for h in hooks.get(hook_type, [])):
|
|
1283
1332
|
return False
|
|
@@ -1313,7 +1362,7 @@ def is_process_alive(pid):
|
|
|
1313
1362
|
|
|
1314
1363
|
try:
|
|
1315
1364
|
pid = int(pid)
|
|
1316
|
-
except (TypeError, ValueError)
|
|
1365
|
+
except (TypeError, ValueError):
|
|
1317
1366
|
return False
|
|
1318
1367
|
|
|
1319
1368
|
if IS_WINDOWS:
|
|
@@ -1350,29 +1399,33 @@ def is_process_alive(pid):
|
|
|
1350
1399
|
|
|
1351
1400
|
kernel32.CloseHandle(handle)
|
|
1352
1401
|
return False # Couldn't get exit code
|
|
1353
|
-
except Exception
|
|
1402
|
+
except Exception:
|
|
1354
1403
|
return False
|
|
1355
1404
|
else:
|
|
1356
1405
|
# Unix: Use os.kill with signal 0
|
|
1357
1406
|
try:
|
|
1358
1407
|
os.kill(pid, 0)
|
|
1359
1408
|
return True
|
|
1360
|
-
except ProcessLookupError
|
|
1409
|
+
except ProcessLookupError:
|
|
1361
1410
|
return False
|
|
1362
|
-
except Exception
|
|
1411
|
+
except Exception:
|
|
1363
1412
|
return False
|
|
1364
1413
|
|
|
1365
|
-
|
|
1414
|
+
class LogParseResult(NamedTuple):
|
|
1415
|
+
"""Result from parsing log messages"""
|
|
1416
|
+
messages: list[dict[str, str]]
|
|
1417
|
+
end_position: int
|
|
1418
|
+
|
|
1419
|
+
def parse_log_messages(log_file: Path, start_pos: int = 0) -> LogParseResult:
|
|
1366
1420
|
"""Parse messages from log file
|
|
1367
1421
|
Args:
|
|
1368
1422
|
log_file: Path to log file
|
|
1369
1423
|
start_pos: Position to start reading from
|
|
1370
|
-
return_end_pos: If True, return tuple (messages, end_position)
|
|
1371
1424
|
Returns:
|
|
1372
|
-
|
|
1425
|
+
LogParseResult containing messages and end position
|
|
1373
1426
|
"""
|
|
1374
1427
|
if not log_file.exists():
|
|
1375
|
-
return ([], start_pos)
|
|
1428
|
+
return LogParseResult([], start_pos)
|
|
1376
1429
|
|
|
1377
1430
|
def read_messages(f):
|
|
1378
1431
|
f.seek(start_pos)
|
|
@@ -1380,7 +1433,7 @@ def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
|
|
|
1380
1433
|
end_pos = f.tell() # Capture actual end position
|
|
1381
1434
|
|
|
1382
1435
|
if not content.strip():
|
|
1383
|
-
return ([], end_pos)
|
|
1436
|
+
return LogParseResult([], end_pos)
|
|
1384
1437
|
|
|
1385
1438
|
messages = []
|
|
1386
1439
|
message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
|
|
@@ -1398,18 +1451,20 @@ def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
|
|
|
1398
1451
|
'message': message.replace('\\|', '|')
|
|
1399
1452
|
})
|
|
1400
1453
|
|
|
1401
|
-
return (messages, end_pos)
|
|
1454
|
+
return LogParseResult(messages, end_pos)
|
|
1402
1455
|
|
|
1403
|
-
|
|
1456
|
+
return read_file_with_retry(
|
|
1404
1457
|
log_file,
|
|
1405
1458
|
read_messages,
|
|
1406
|
-
default=([], start_pos)
|
|
1459
|
+
default=LogParseResult([], start_pos)
|
|
1407
1460
|
)
|
|
1408
1461
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1462
|
+
def get_unread_messages(instance_name: str, update_position: bool = False) -> list[dict[str, str]]:
|
|
1463
|
+
"""Get unread messages for instance with @-mention filtering
|
|
1464
|
+
Args:
|
|
1465
|
+
instance_name: Name of instance to get messages for
|
|
1466
|
+
update_position: If True, mark messages as read by updating position
|
|
1467
|
+
"""
|
|
1413
1468
|
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
1414
1469
|
|
|
1415
1470
|
if not log_file.exists():
|
|
@@ -1424,7 +1479,8 @@ def get_new_messages(instance_name):
|
|
|
1424
1479
|
last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
|
|
1425
1480
|
|
|
1426
1481
|
# Atomic read with position tracking
|
|
1427
|
-
|
|
1482
|
+
result = parse_log_messages(log_file, last_pos)
|
|
1483
|
+
all_messages, new_pos = result.messages, result.end_position
|
|
1428
1484
|
|
|
1429
1485
|
# Filter messages:
|
|
1430
1486
|
# 1. Exclude own messages
|
|
@@ -1436,12 +1492,13 @@ def get_new_messages(instance_name):
|
|
|
1436
1492
|
if should_deliver_message(msg, instance_name, all_instance_names):
|
|
1437
1493
|
messages.append(msg)
|
|
1438
1494
|
|
|
1439
|
-
#
|
|
1440
|
-
|
|
1495
|
+
# Only update position (ie mark as read) if explicitly requested (after successful delivery)
|
|
1496
|
+
if update_position:
|
|
1497
|
+
update_instance_position(instance_name, {'pos': new_pos})
|
|
1441
1498
|
|
|
1442
1499
|
return messages
|
|
1443
1500
|
|
|
1444
|
-
def format_age(seconds):
|
|
1501
|
+
def format_age(seconds: float) -> str:
|
|
1445
1502
|
"""Format time ago in human readable form"""
|
|
1446
1503
|
if seconds < 60:
|
|
1447
1504
|
return f"{int(seconds)}s"
|
|
@@ -1450,117 +1507,35 @@ def format_age(seconds):
|
|
|
1450
1507
|
else:
|
|
1451
1508
|
return f"{int(seconds/3600)}h"
|
|
1452
1509
|
|
|
1453
|
-
def
|
|
1454
|
-
"""
|
|
1455
|
-
|
|
1456
|
-
return "inactive", "", "", 0
|
|
1457
|
-
|
|
1458
|
-
def read_status(f):
|
|
1459
|
-
# Windows file buffering fix: read entire file to get current content
|
|
1460
|
-
if IS_WINDOWS:
|
|
1461
|
-
# Seek to beginning and read all content to bypass Windows file caching
|
|
1462
|
-
f.seek(0)
|
|
1463
|
-
all_content = f.read()
|
|
1464
|
-
all_lines = all_content.strip().split('\n')
|
|
1465
|
-
lines = all_lines[-5:] if len(all_lines) >= 5 else all_lines
|
|
1466
|
-
else:
|
|
1467
|
-
lines = f.readlines()[-5:]
|
|
1468
|
-
|
|
1469
|
-
for i, line in enumerate(reversed(lines)):
|
|
1470
|
-
try:
|
|
1471
|
-
entry = json.loads(line)
|
|
1472
|
-
timestamp = datetime.fromisoformat(entry['timestamp']).timestamp()
|
|
1473
|
-
age = int(time.time() - timestamp)
|
|
1474
|
-
entry_type = entry.get('type', '')
|
|
1475
|
-
|
|
1476
|
-
if entry['type'] == 'system':
|
|
1477
|
-
content = entry.get('content', '')
|
|
1478
|
-
if 'Running' in content:
|
|
1479
|
-
tool_name = content.split('Running ')[1].split('[')[0].strip()
|
|
1480
|
-
return "executing", f"({format_age(age)})", tool_name, timestamp
|
|
1481
|
-
|
|
1482
|
-
elif entry['type'] == 'assistant':
|
|
1483
|
-
content = entry.get('content', [])
|
|
1484
|
-
has_tool_use = any('tool_use' in str(item) for item in content)
|
|
1485
|
-
if has_tool_use:
|
|
1486
|
-
return "executing", f"({format_age(age)})", "tool", timestamp
|
|
1487
|
-
else:
|
|
1488
|
-
return "responding", f"({format_age(age)})", "", timestamp
|
|
1489
|
-
|
|
1490
|
-
elif entry['type'] == 'user':
|
|
1491
|
-
return "thinking", f"({format_age(age)})", "", timestamp
|
|
1492
|
-
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
1493
|
-
continue
|
|
1494
|
-
|
|
1495
|
-
return "inactive", "", "", 0
|
|
1496
|
-
|
|
1497
|
-
try:
|
|
1498
|
-
result = read_file_with_retry(
|
|
1499
|
-
transcript_path,
|
|
1500
|
-
read_status,
|
|
1501
|
-
default=("inactive", "", "", 0)
|
|
1502
|
-
)
|
|
1503
|
-
return result
|
|
1504
|
-
except Exception:
|
|
1505
|
-
return "inactive", "", "", 0
|
|
1506
|
-
|
|
1507
|
-
def get_instance_status(pos_data):
|
|
1508
|
-
"""Get current status of instance"""
|
|
1510
|
+
def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str]:
|
|
1511
|
+
"""Get current status of instance. Returns (status_type, age_string)."""
|
|
1512
|
+
# Returns: (display_category, formatted_age) - category for color, age for display
|
|
1509
1513
|
now = int(time.time())
|
|
1510
|
-
wait_timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
|
|
1511
|
-
|
|
1512
|
-
# Check if process is still alive. pid: null means killed
|
|
1513
|
-
# All real instances should have a PID (set by update_instance_with_pid)
|
|
1514
|
-
if 'pid' in pos_data:
|
|
1515
|
-
pid = pos_data['pid']
|
|
1516
|
-
if pid is None:
|
|
1517
|
-
# Explicitly null = was killed
|
|
1518
|
-
return "inactive", ""
|
|
1519
|
-
if not is_process_alive(pid):
|
|
1520
|
-
# On Windows, PID checks can fail during process transitions
|
|
1521
|
-
# Let timeout logic handle this using activity timestamps
|
|
1522
|
-
wait_timeout = 30 if IS_WINDOWS else wait_timeout # Shorter timeout when PID dead
|
|
1523
|
-
|
|
1524
|
-
last_permission = pos_data.get("last_permission_request", 0)
|
|
1525
|
-
last_stop = pos_data.get("last_stop", 0)
|
|
1526
|
-
last_tool = pos_data.get("last_tool", 0)
|
|
1527
|
-
|
|
1528
|
-
transcript_timestamp = 0
|
|
1529
|
-
transcript_status = "inactive"
|
|
1530
|
-
|
|
1531
|
-
transcript_path = pos_data.get("transcript_path", "")
|
|
1532
|
-
if transcript_path:
|
|
1533
|
-
status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
|
|
1534
|
-
transcript_status = status
|
|
1535
|
-
|
|
1536
|
-
# Calculate last actual activity (excluding heartbeat)
|
|
1537
|
-
last_activity = max(last_permission, last_tool, transcript_timestamp)
|
|
1538
1514
|
|
|
1539
|
-
# Check
|
|
1540
|
-
if
|
|
1515
|
+
# Check if killed
|
|
1516
|
+
if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
|
|
1541
1517
|
return "inactive", ""
|
|
1542
1518
|
|
|
1543
|
-
#
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
(last_stop, "waiting"),
|
|
1547
|
-
(last_tool, "inactive"),
|
|
1548
|
-
(transcript_timestamp, transcript_status)
|
|
1549
|
-
]
|
|
1519
|
+
# Get last known status
|
|
1520
|
+
last_status = pos_data.get('last_status', '')
|
|
1521
|
+
last_status_time = pos_data.get('last_status_time', 0)
|
|
1550
1522
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
return "inactive", ""
|
|
1523
|
+
if not last_status or not last_status_time:
|
|
1524
|
+
return "unknown", ""
|
|
1554
1525
|
|
|
1555
|
-
|
|
1556
|
-
|
|
1526
|
+
# Get display category from STATUS_INFO
|
|
1527
|
+
display_status, _ = STATUS_INFO.get(last_status, ('unknown', ''))
|
|
1557
1528
|
|
|
1558
|
-
|
|
1559
|
-
|
|
1529
|
+
# Check timeout
|
|
1530
|
+
age = now - last_status_time
|
|
1531
|
+
timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
|
|
1532
|
+
if age > timeout:
|
|
1533
|
+
return "inactive", ""
|
|
1560
1534
|
|
|
1561
|
-
|
|
1535
|
+
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1536
|
+
return display_status, f"({format_age(age)}){status_suffix}"
|
|
1562
1537
|
|
|
1563
|
-
def get_status_block(status_type):
|
|
1538
|
+
def get_status_block(status_type: str) -> str:
|
|
1564
1539
|
"""Get colored status block for a status type"""
|
|
1565
1540
|
color, symbol = STATUS_MAP.get(status_type, (BG_RED, "?"))
|
|
1566
1541
|
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
@@ -1611,7 +1586,7 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1611
1586
|
|
|
1612
1587
|
log_file = hcom_path(LOG_FILE)
|
|
1613
1588
|
if log_file.exists():
|
|
1614
|
-
messages = parse_log_messages(log_file)
|
|
1589
|
+
messages = parse_log_messages(log_file).messages
|
|
1615
1590
|
show_recent_messages(messages, limit, truncate=True)
|
|
1616
1591
|
|
|
1617
1592
|
def show_instances_by_directory():
|
|
@@ -1634,11 +1609,19 @@ def show_instances_by_directory():
|
|
|
1634
1609
|
for instance_name, pos_data in instances:
|
|
1635
1610
|
status_type, age = get_instance_status(pos_data)
|
|
1636
1611
|
status_block = get_status_block(status_type)
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1612
|
+
|
|
1613
|
+
# Format status description using STATUS_INFO and context
|
|
1614
|
+
last_status = pos_data.get('last_status', '')
|
|
1615
|
+
last_context = pos_data.get('last_status_context', '')
|
|
1616
|
+
_, desc_template = STATUS_INFO.get(last_status, ('unknown', ''))
|
|
1617
|
+
|
|
1618
|
+
# Format description with context if template has {}
|
|
1619
|
+
if '{}' in desc_template and last_context:
|
|
1620
|
+
status_desc = desc_template.format(last_context)
|
|
1621
|
+
else:
|
|
1622
|
+
status_desc = desc_template
|
|
1623
|
+
|
|
1624
|
+
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_desc} {age}{RESET}")
|
|
1642
1625
|
print()
|
|
1643
1626
|
else:
|
|
1644
1627
|
print(f" {DIM}Error reading instance data{RESET}")
|
|
@@ -1678,15 +1661,15 @@ def get_status_summary():
|
|
|
1678
1661
|
if not positions:
|
|
1679
1662
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1680
1663
|
|
|
1681
|
-
status_counts = {
|
|
1664
|
+
status_counts = {status: 0 for status in STATUS_MAP.keys()}
|
|
1682
1665
|
|
|
1683
|
-
for
|
|
1666
|
+
for _, pos_data in positions.items():
|
|
1684
1667
|
status_type, _ = get_instance_status(pos_data)
|
|
1685
1668
|
if status_type in status_counts:
|
|
1686
1669
|
status_counts[status_type] += 1
|
|
1687
1670
|
|
|
1688
1671
|
parts = []
|
|
1689
|
-
status_order = ["
|
|
1672
|
+
status_order = ["active", "delivered", "waiting", "blocked", "inactive", "unknown"]
|
|
1690
1673
|
|
|
1691
1674
|
for status_type in status_order:
|
|
1692
1675
|
count = status_counts[status_type]
|
|
@@ -1721,11 +1704,8 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1721
1704
|
defaults = {
|
|
1722
1705
|
"pos": 0,
|
|
1723
1706
|
"directory": str(Path.cwd()),
|
|
1724
|
-
"last_tool": 0,
|
|
1725
|
-
"last_tool_name": "unknown",
|
|
1726
1707
|
"last_stop": 0,
|
|
1727
|
-
"
|
|
1728
|
-
"session_ids": [session_id] if session_id else [],
|
|
1708
|
+
"session_id": session_id or "",
|
|
1729
1709
|
"transcript_path": "",
|
|
1730
1710
|
"notification_message": "",
|
|
1731
1711
|
"alias_announced": False
|
|
@@ -1758,12 +1738,19 @@ def update_instance_position(instance_name, update_fields):
|
|
|
1758
1738
|
else:
|
|
1759
1739
|
raise
|
|
1760
1740
|
|
|
1741
|
+
def set_status(instance_name: str, status: str, context: str = ''):
|
|
1742
|
+
"""Set instance status event with timestamp"""
|
|
1743
|
+
update_instance_position(instance_name, {
|
|
1744
|
+
'last_status': status,
|
|
1745
|
+
'last_status_time': int(time.time()),
|
|
1746
|
+
'last_status_context': context
|
|
1747
|
+
})
|
|
1748
|
+
log_hook_error(f"Set status for {instance_name} to {status} with context {context}") #TODO: change to 'log'?
|
|
1749
|
+
|
|
1761
1750
|
def merge_instance_data(to_data, from_data):
|
|
1762
1751
|
"""Merge instance data from from_data into to_data."""
|
|
1763
|
-
#
|
|
1764
|
-
|
|
1765
|
-
from_sessions = from_data.get('session_ids', [])
|
|
1766
|
-
to_data['session_ids'] = list(dict.fromkeys(to_sessions + from_sessions))
|
|
1752
|
+
# Use current session_id from source (overwrites previous)
|
|
1753
|
+
to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
|
|
1767
1754
|
|
|
1768
1755
|
# Update transient fields from source
|
|
1769
1756
|
to_data['pid'] = os.getppid() # Always use current PID
|
|
@@ -1775,14 +1762,16 @@ def merge_instance_data(to_data, from_data):
|
|
|
1775
1762
|
# Update directory to most recent
|
|
1776
1763
|
to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
|
|
1777
1764
|
|
|
1778
|
-
# Update
|
|
1779
|
-
to_data['last_tool'] = max(to_data.get('last_tool', 0), from_data.get('last_tool', 0))
|
|
1780
|
-
to_data['last_tool_name'] = from_data.get('last_tool_name', to_data.get('last_tool_name', 'unknown'))
|
|
1765
|
+
# Update heartbeat timestamp to most recent
|
|
1781
1766
|
to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
)
|
|
1767
|
+
|
|
1768
|
+
# Merge new status fields - take most recent status event
|
|
1769
|
+
from_time = from_data.get('last_status_time', 0)
|
|
1770
|
+
to_time = to_data.get('last_status_time', 0)
|
|
1771
|
+
if from_time > to_time:
|
|
1772
|
+
to_data['last_status'] = from_data.get('last_status', '')
|
|
1773
|
+
to_data['last_status_time'] = from_time
|
|
1774
|
+
to_data['last_status_context'] = from_data.get('last_status_context', '')
|
|
1786
1775
|
|
|
1787
1776
|
# Preserve background mode if set
|
|
1788
1777
|
to_data['background'] = to_data.get('background') or from_data.get('background')
|
|
@@ -1814,11 +1803,15 @@ def merge_instance_immediately(from_name, to_name):
|
|
|
1814
1803
|
from_data = load_instance_position(from_name)
|
|
1815
1804
|
to_data = load_instance_position(to_name)
|
|
1816
1805
|
|
|
1817
|
-
# Check if target
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1806
|
+
# Check if target has recent activity (time-based check instead of PID)
|
|
1807
|
+
now = time.time()
|
|
1808
|
+
last_activity = max(
|
|
1809
|
+
to_data.get('last_stop', 0),
|
|
1810
|
+
to_data.get('last_status_time', 0)
|
|
1811
|
+
)
|
|
1812
|
+
time_since_activity = now - last_activity
|
|
1813
|
+
if time_since_activity < MERGE_ACTIVITY_THRESHOLD:
|
|
1814
|
+
return f"Cannot recover {to_name}: instance is active (activity {int(time_since_activity)}s ago)"
|
|
1822
1815
|
|
|
1823
1816
|
# Merge data using helper
|
|
1824
1817
|
to_data = merge_instance_data(to_data, from_data)
|
|
@@ -1830,12 +1823,12 @@ def merge_instance_immediately(from_name, to_name):
|
|
|
1830
1823
|
# Cleanup source file only after successful save
|
|
1831
1824
|
try:
|
|
1832
1825
|
hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
|
|
1833
|
-
except:
|
|
1826
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
1834
1827
|
pass # Non-critical if cleanup fails
|
|
1835
1828
|
|
|
1836
|
-
return f"[SUCCESS] ✓ Recovered: {
|
|
1829
|
+
return f"[SUCCESS] ✓ Recovered alias: {to_name}"
|
|
1837
1830
|
except Exception:
|
|
1838
|
-
return f"Failed to
|
|
1831
|
+
return f"Failed to recover alias: {to_name}"
|
|
1839
1832
|
|
|
1840
1833
|
|
|
1841
1834
|
# ==================== Command Functions ====================
|
|
@@ -1847,9 +1840,8 @@ def show_main_screen_header():
|
|
|
1847
1840
|
log_file = hcom_path(LOG_FILE)
|
|
1848
1841
|
all_messages = []
|
|
1849
1842
|
if log_file.exists():
|
|
1850
|
-
all_messages = parse_log_messages(log_file)
|
|
1851
|
-
|
|
1852
|
-
|
|
1843
|
+
all_messages = parse_log_messages(log_file).messages
|
|
1844
|
+
|
|
1853
1845
|
print(f"{BOLD}HCOM{RESET} LOGS")
|
|
1854
1846
|
print(f"{DIM}{'─'*40}{RESET}\n")
|
|
1855
1847
|
|
|
@@ -1898,13 +1890,15 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
|
|
|
1898
1890
|
|
|
1899
1891
|
=== ADDITIONAL INFO ===
|
|
1900
1892
|
|
|
1901
|
-
CONCEPT: HCOM
|
|
1902
|
-
|
|
1893
|
+
CONCEPT: HCOM launches Claude Code instances in new terminal windows.
|
|
1894
|
+
They communicate with each other via a shared conversation.
|
|
1895
|
+
You communicate with them via hcom automation commands.
|
|
1903
1896
|
|
|
1904
1897
|
KEY UNDERSTANDING:
|
|
1905
1898
|
• Single conversation - All instances share ~/.hcom/hcom.log
|
|
1906
|
-
•
|
|
1907
|
-
•
|
|
1899
|
+
• Messaging - Use 'hcom send "message"' from CLI to send messages to instances
|
|
1900
|
+
• Instances receive messages via hooks automatically and send with 'eval $HCOM send "message"'
|
|
1901
|
+
• hcom open is directory-specific - always cd to project directory first
|
|
1908
1902
|
• hcom watch --wait outputs existing logs, then waits for the next message, prints it, and exits.
|
|
1909
1903
|
Times out after [seconds]
|
|
1910
1904
|
• Named agents are custom system prompts created by users/claude code.
|
|
@@ -1912,7 +1906,7 @@ Times out after [seconds]
|
|
|
1912
1906
|
|
|
1913
1907
|
LAUNCH PATTERNS:
|
|
1914
1908
|
hcom open 2 reviewer # 2 generic + 1 reviewer agent
|
|
1915
|
-
hcom open reviewer reviewer # 2 separate reviewer instances
|
|
1909
|
+
hcom open reviewer reviewer # 2 separate reviewer instances
|
|
1916
1910
|
hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
|
|
1917
1911
|
hcom open --claude-args "--model sonnet" # Pass 'claude' CLI flags
|
|
1918
1912
|
hcom open --background (or -p) then hcom kill # Detached background process
|
|
@@ -1926,10 +1920,12 @@ LAUNCH PATTERNS:
|
|
|
1926
1920
|
(Unmatched @mentions broadcast to everyone)
|
|
1927
1921
|
|
|
1928
1922
|
STATUS INDICATORS:
|
|
1929
|
-
•
|
|
1923
|
+
• ▶ active - instance is working (processing/executing)
|
|
1924
|
+
• ▷ delivered - instance just received a message
|
|
1930
1925
|
• ◉ waiting - instance is waiting for new messages
|
|
1931
1926
|
• ■ blocked - instance is blocked by permission request (needs user approval)
|
|
1932
1927
|
• ○ inactive - instance is timed out, disconnected, etc
|
|
1928
|
+
• ○ unknown - no status information available
|
|
1933
1929
|
|
|
1934
1930
|
CONFIG:
|
|
1935
1931
|
Config file (persistent): ~/.hcom/config.json
|
|
@@ -1962,14 +1958,6 @@ def cmd_open(*args):
|
|
|
1962
1958
|
# Parse arguments
|
|
1963
1959
|
instances, prefix, claude_args, background = parse_open_args(list(args))
|
|
1964
1960
|
|
|
1965
|
-
# Extract resume sessionId if present
|
|
1966
|
-
resume_session_id = None
|
|
1967
|
-
if claude_args:
|
|
1968
|
-
for i, arg in enumerate(claude_args):
|
|
1969
|
-
if arg in ['--resume', '-r'] and i + 1 < len(claude_args):
|
|
1970
|
-
resume_session_id = claude_args[i + 1]
|
|
1971
|
-
break
|
|
1972
|
-
|
|
1973
1961
|
# Add -p flag and stream-json output for background mode if not already present
|
|
1974
1962
|
if background and '-p' not in claude_args and '--print' not in claude_args:
|
|
1975
1963
|
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
@@ -2000,32 +1988,25 @@ def cmd_open(*args):
|
|
|
2000
1988
|
# Build environment variables for Claude instances
|
|
2001
1989
|
base_env = build_claude_env()
|
|
2002
1990
|
|
|
2003
|
-
# Pass resume sessionId to hooks (only for first instance if multiple)
|
|
2004
|
-
# This avoids conflicts when resuming with -n > 1
|
|
2005
|
-
if resume_session_id:
|
|
2006
|
-
if len(instances) > 1:
|
|
2007
|
-
print(f"Warning: --resume with {len(instances)} instances will only resume the first instance", file=sys.stderr)
|
|
2008
|
-
# Will be added to first instance env only
|
|
2009
|
-
|
|
2010
1991
|
# Add prefix-specific hints if provided
|
|
2011
1992
|
if prefix:
|
|
2012
1993
|
base_env['HCOM_PREFIX'] = prefix
|
|
2013
|
-
|
|
1994
|
+
send_cmd = build_send_command()
|
|
1995
|
+
hint = f"To respond to {prefix} group: {send_cmd} '@{prefix} message'"
|
|
2014
1996
|
base_env['HCOM_INSTANCE_HINTS'] = hint
|
|
2015
|
-
|
|
2016
|
-
first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
|
|
1997
|
+
first_use = f"You're in the {prefix} group. Use {prefix} to message: {send_cmd} '@{prefix} message'"
|
|
2017
1998
|
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
2018
1999
|
|
|
2019
2000
|
launched = 0
|
|
2020
2001
|
initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
|
|
2021
2002
|
|
|
2022
|
-
for
|
|
2003
|
+
for _, instance_type in enumerate(instances):
|
|
2023
2004
|
instance_env = base_env.copy()
|
|
2024
2005
|
|
|
2025
|
-
#
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2006
|
+
# Set unique launch ID for sender detection in cmd_send()
|
|
2007
|
+
launch_id = f"{int(time.time())}_{random.randint(10000, 99999)}"
|
|
2008
|
+
instance_env['HCOM_LAUNCH_ID'] = launch_id
|
|
2009
|
+
|
|
2029
2010
|
# Mark background instances via environment with log filename
|
|
2030
2011
|
if background:
|
|
2031
2012
|
# Generate unique log filename
|
|
@@ -2035,7 +2016,7 @@ def cmd_open(*args):
|
|
|
2035
2016
|
# Build claude command
|
|
2036
2017
|
if instance_type == 'generic':
|
|
2037
2018
|
# Generic instance - no agent content
|
|
2038
|
-
claude_cmd,
|
|
2019
|
+
claude_cmd, _ = build_claude_command(
|
|
2039
2020
|
agent_content=None,
|
|
2040
2021
|
claude_args=claude_args,
|
|
2041
2022
|
initial_prompt=initial_prompt
|
|
@@ -2052,7 +2033,7 @@ def cmd_open(*args):
|
|
|
2052
2033
|
# Use agent's model and tools if specified and not overridden in claude_args
|
|
2053
2034
|
agent_model = agent_config.get('model')
|
|
2054
2035
|
agent_tools = agent_config.get('tools')
|
|
2055
|
-
claude_cmd,
|
|
2036
|
+
claude_cmd, _ = build_claude_command(
|
|
2056
2037
|
agent_content=agent_content,
|
|
2057
2038
|
claude_args=claude_args,
|
|
2058
2039
|
initial_prompt=initial_prompt,
|
|
@@ -2168,7 +2149,7 @@ def cmd_watch(*args):
|
|
|
2168
2149
|
# Atomic position capture BEFORE parsing (prevents race condition)
|
|
2169
2150
|
if log_file.exists():
|
|
2170
2151
|
last_pos = log_file.stat().st_size # Capture position first
|
|
2171
|
-
messages = parse_log_messages(log_file)
|
|
2152
|
+
messages = parse_log_messages(log_file).messages
|
|
2172
2153
|
else:
|
|
2173
2154
|
last_pos = 0
|
|
2174
2155
|
messages = []
|
|
@@ -2180,7 +2161,7 @@ def cmd_watch(*args):
|
|
|
2180
2161
|
|
|
2181
2162
|
# Status to stderr, data to stdout
|
|
2182
2163
|
if recent_messages:
|
|
2183
|
-
print(f'---Showing last 5 seconds of messages---', file=sys.stderr)
|
|
2164
|
+
print(f'---Showing last 5 seconds of messages---', file=sys.stderr) #TODO: change this to recent messages and have logic like last 3 messages + all messages in last 5 seconds.
|
|
2184
2165
|
for msg in recent_messages:
|
|
2185
2166
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2186
2167
|
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
@@ -2196,7 +2177,7 @@ def cmd_watch(*args):
|
|
|
2196
2177
|
new_messages = []
|
|
2197
2178
|
if current_size > last_pos:
|
|
2198
2179
|
# Capture new position BEFORE parsing (atomic)
|
|
2199
|
-
new_messages = parse_log_messages(log_file, last_pos)
|
|
2180
|
+
new_messages = parse_log_messages(log_file, last_pos).messages
|
|
2200
2181
|
if new_messages:
|
|
2201
2182
|
for msg in new_messages:
|
|
2202
2183
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
@@ -2233,9 +2214,10 @@ def cmd_watch(*args):
|
|
|
2233
2214
|
"status": status,
|
|
2234
2215
|
"age": age.strip() if age else "",
|
|
2235
2216
|
"directory": data.get("directory", "unknown"),
|
|
2236
|
-
"
|
|
2237
|
-
"
|
|
2238
|
-
"
|
|
2217
|
+
"session_id": data.get("session_id", ""),
|
|
2218
|
+
"last_status": data.get("last_status", ""),
|
|
2219
|
+
"last_status_time": data.get("last_status_time", 0),
|
|
2220
|
+
"last_status_context": data.get("last_status_context", ""),
|
|
2239
2221
|
"pid": data.get("pid"),
|
|
2240
2222
|
"background": bool(data.get("background"))
|
|
2241
2223
|
}
|
|
@@ -2244,7 +2226,7 @@ def cmd_watch(*args):
|
|
|
2244
2226
|
# Get recent messages
|
|
2245
2227
|
messages = []
|
|
2246
2228
|
if log_file.exists():
|
|
2247
|
-
all_messages = parse_log_messages(log_file)
|
|
2229
|
+
all_messages = parse_log_messages(log_file).messages
|
|
2248
2230
|
messages = all_messages[-5:] if all_messages else []
|
|
2249
2231
|
|
|
2250
2232
|
# Output JSON
|
|
@@ -2264,6 +2246,7 @@ def cmd_watch(*args):
|
|
|
2264
2246
|
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
2265
2247
|
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
2266
2248
|
print(" hcom watch --wait Wait for new messages", file=sys.stderr)
|
|
2249
|
+
print(" Full information: hcom --help")
|
|
2267
2250
|
|
|
2268
2251
|
show_cli_hints()
|
|
2269
2252
|
|
|
@@ -2282,10 +2265,9 @@ def cmd_watch(*args):
|
|
|
2282
2265
|
|
|
2283
2266
|
show_recent_messages(all_messages, limit=5)
|
|
2284
2267
|
print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
|
|
2285
|
-
print(f"{DIM}{'─' * 40}{RESET}")
|
|
2286
2268
|
|
|
2287
2269
|
# Print newline to ensure status starts on its own line
|
|
2288
|
-
|
|
2270
|
+
print()
|
|
2289
2271
|
|
|
2290
2272
|
current_status = get_status_summary()
|
|
2291
2273
|
update_status(f"{current_status}{status_suffix}")
|
|
@@ -2309,7 +2291,7 @@ def cmd_watch(*args):
|
|
|
2309
2291
|
if log_file.exists():
|
|
2310
2292
|
current_size = log_file.stat().st_size
|
|
2311
2293
|
if current_size > last_pos:
|
|
2312
|
-
new_messages = parse_log_messages(log_file, last_pos)
|
|
2294
|
+
new_messages = parse_log_messages(log_file, last_pos).messages
|
|
2313
2295
|
# Use the last known status for consistency
|
|
2314
2296
|
status_line_text = f"{last_status}{status_suffix}"
|
|
2315
2297
|
for msg in new_messages:
|
|
@@ -2319,9 +2301,9 @@ def cmd_watch(*args):
|
|
|
2319
2301
|
# Check for keyboard input
|
|
2320
2302
|
ready_for_input = False
|
|
2321
2303
|
if IS_WINDOWS:
|
|
2322
|
-
import msvcrt
|
|
2323
|
-
if msvcrt.kbhit():
|
|
2324
|
-
msvcrt.getch()
|
|
2304
|
+
import msvcrt # type: ignore[import]
|
|
2305
|
+
if msvcrt.kbhit(): # type: ignore[attr-defined]
|
|
2306
|
+
msvcrt.getch() # type: ignore[attr-defined]
|
|
2325
2307
|
ready_for_input = True
|
|
2326
2308
|
else:
|
|
2327
2309
|
if select.select([sys.stdin], [], [], 0.1)[0]:
|
|
@@ -2380,6 +2362,13 @@ def cmd_clear():
|
|
|
2380
2362
|
if script_count > 0:
|
|
2381
2363
|
print(f"Cleaned up {script_count} old script files")
|
|
2382
2364
|
|
|
2365
|
+
# Clean up old launch mapping files (older than 24 hours)
|
|
2366
|
+
if instances_dir.exists():
|
|
2367
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2368
|
+
mapping_count = sum(1 for f in instances_dir.glob('.launch_map_*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
|
|
2369
|
+
if mapping_count > 0:
|
|
2370
|
+
print(f"Cleaned up {mapping_count} old launch mapping files")
|
|
2371
|
+
|
|
2383
2372
|
# Check if hcom files exist
|
|
2384
2373
|
if not log_file.exists() and not instances_dir.exists():
|
|
2385
2374
|
print("No hcom conversation to clear")
|
|
@@ -2410,11 +2399,15 @@ def cmd_clear():
|
|
|
2410
2399
|
if has_instances:
|
|
2411
2400
|
archive_instances = session_archive / INSTANCES_DIR
|
|
2412
2401
|
archive_instances.mkdir(exist_ok=True)
|
|
2413
|
-
|
|
2402
|
+
|
|
2414
2403
|
# Move json files only
|
|
2415
2404
|
for f in instances_dir.glob('*.json'):
|
|
2416
2405
|
f.rename(archive_instances / f.name)
|
|
2417
|
-
|
|
2406
|
+
|
|
2407
|
+
# Clean up orphaned mapping files (position files are archived)
|
|
2408
|
+
for f in instances_dir.glob('.launch_map_*'):
|
|
2409
|
+
f.unlink(missing_ok=True)
|
|
2410
|
+
|
|
2418
2411
|
archived = True
|
|
2419
2412
|
else:
|
|
2420
2413
|
# Clean up empty files/dirs
|
|
@@ -2458,14 +2451,15 @@ def cleanup_directory_hooks(directory):
|
|
|
2458
2451
|
|
|
2459
2452
|
hooks_found = False
|
|
2460
2453
|
|
|
2454
|
+
# Include PostToolUse for backward compatibility cleanup
|
|
2461
2455
|
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2462
|
-
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2463
|
-
|
|
2456
|
+
for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2457
|
+
|
|
2464
2458
|
_remove_hcom_hooks_from_settings(settings)
|
|
2465
|
-
|
|
2459
|
+
|
|
2466
2460
|
# Check if any were removed
|
|
2467
2461
|
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2468
|
-
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2462
|
+
for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2469
2463
|
if new_hook_count < original_hook_count:
|
|
2470
2464
|
hooks_found = True
|
|
2471
2465
|
|
|
@@ -2529,6 +2523,7 @@ def cmd_kill(*args):
|
|
|
2529
2523
|
|
|
2530
2524
|
# Mark instance as killed
|
|
2531
2525
|
update_instance_position(target_name, {'pid': None})
|
|
2526
|
+
set_status(target_name, 'killed')
|
|
2532
2527
|
|
|
2533
2528
|
if not instance_name:
|
|
2534
2529
|
print(f"Killed {killed_count} instance(s)")
|
|
@@ -2597,35 +2592,58 @@ def cmd_send(message):
|
|
|
2597
2592
|
# Check if hcom files exist
|
|
2598
2593
|
log_file = hcom_path(LOG_FILE)
|
|
2599
2594
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2600
|
-
|
|
2595
|
+
|
|
2601
2596
|
if not log_file.exists() and not instances_dir.exists():
|
|
2602
2597
|
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2603
2598
|
return 1
|
|
2604
|
-
|
|
2599
|
+
|
|
2605
2600
|
# Validate message
|
|
2606
2601
|
error = validate_message(message)
|
|
2607
2602
|
if error:
|
|
2608
2603
|
print(error, file=sys.stderr)
|
|
2609
2604
|
return 1
|
|
2610
|
-
|
|
2605
|
+
|
|
2611
2606
|
# Check for unmatched mentions (minimal warning)
|
|
2612
2607
|
mentions = MENTION_PATTERN.findall(message)
|
|
2613
2608
|
if mentions:
|
|
2614
2609
|
try:
|
|
2615
2610
|
positions = load_all_positions()
|
|
2616
2611
|
all_instances = list(positions.keys())
|
|
2617
|
-
unmatched = [m for m in mentions
|
|
2612
|
+
unmatched = [m for m in mentions
|
|
2618
2613
|
if not any(name.lower().startswith(m.lower()) for name in all_instances)]
|
|
2619
2614
|
if unmatched:
|
|
2620
2615
|
print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
|
|
2621
2616
|
except Exception:
|
|
2622
2617
|
pass # Don't fail on warning
|
|
2623
|
-
|
|
2624
|
-
#
|
|
2625
|
-
sender_name =
|
|
2626
|
-
|
|
2618
|
+
|
|
2619
|
+
# Determine sender: lookup by launch_id, fallback to config
|
|
2620
|
+
sender_name = None
|
|
2621
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2622
|
+
if launch_id:
|
|
2623
|
+
try:
|
|
2624
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
|
|
2625
|
+
if mapping_file.exists():
|
|
2626
|
+
sender_name = mapping_file.read_text(encoding='utf-8').strip()
|
|
2627
|
+
except Exception:
|
|
2628
|
+
pass
|
|
2629
|
+
|
|
2630
|
+
if not sender_name:
|
|
2631
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
2632
|
+
|
|
2627
2633
|
if send_message(sender_name, message):
|
|
2628
|
-
|
|
2634
|
+
# For instances: check for new messages and display immediately
|
|
2635
|
+
if launch_id: # Only for instances with HCOM_LAUNCH_ID
|
|
2636
|
+
messages = get_unread_messages(sender_name, update_position=True)
|
|
2637
|
+
if messages:
|
|
2638
|
+
max_msgs = get_config_value('max_messages_per_delivery', 50)
|
|
2639
|
+
messages_to_show = messages[:max_msgs]
|
|
2640
|
+
formatted = format_hook_messages(messages_to_show, sender_name)
|
|
2641
|
+
print(f"Message sent\n\n{formatted}", file=sys.stderr)
|
|
2642
|
+
else:
|
|
2643
|
+
print("Message sent", file=sys.stderr)
|
|
2644
|
+
else:
|
|
2645
|
+
# Bigboss: just confirm send
|
|
2646
|
+
print("Message sent", file=sys.stderr)
|
|
2629
2647
|
|
|
2630
2648
|
# Show cli_hints if configured (non-interactive mode)
|
|
2631
2649
|
if not is_interactive():
|
|
@@ -2636,7 +2654,50 @@ def cmd_send(message):
|
|
|
2636
2654
|
print(format_error("Failed to send message"), file=sys.stderr)
|
|
2637
2655
|
return 1
|
|
2638
2656
|
|
|
2639
|
-
|
|
2657
|
+
def cmd_resume_merge(alias: str) -> int:
|
|
2658
|
+
"""Resume/merge current instance into an existing instance by alias.
|
|
2659
|
+
|
|
2660
|
+
INTERNAL COMMAND: Only called via '$HCOM send --resume alias' during implicit resume workflow.
|
|
2661
|
+
Not meant for direct CLI usage.
|
|
2662
|
+
"""
|
|
2663
|
+
# Get current instance name via launch_id mapping (same mechanism as cmd_send)
|
|
2664
|
+
# The mapping is created by init_hook_context() when hooks run
|
|
2665
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2666
|
+
if not launch_id:
|
|
2667
|
+
print(format_error("Not in HCOM instance context - no launch ID"), file=sys.stderr)
|
|
2668
|
+
return 1
|
|
2669
|
+
|
|
2670
|
+
instance_name = None
|
|
2671
|
+
try:
|
|
2672
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
|
|
2673
|
+
if mapping_file.exists():
|
|
2674
|
+
instance_name = mapping_file.read_text(encoding='utf-8').strip()
|
|
2675
|
+
except Exception:
|
|
2676
|
+
pass
|
|
2677
|
+
|
|
2678
|
+
if not instance_name:
|
|
2679
|
+
print(format_error("Could not determine instance name"), file=sys.stderr)
|
|
2680
|
+
return 1
|
|
2681
|
+
|
|
2682
|
+
# Sanitize alias: only allow alphanumeric, dash, underscore
|
|
2683
|
+
# This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
|
|
2684
|
+
if not re.match(r'^[A-Za-z0-9\-_]+$', alias):
|
|
2685
|
+
print(format_error("Invalid alias format. Use alphanumeric, dash, or underscore only"), file=sys.stderr)
|
|
2686
|
+
return 1
|
|
2687
|
+
|
|
2688
|
+
# Attempt to merge current instance into target alias
|
|
2689
|
+
status = merge_instance_immediately(instance_name, alias)
|
|
2690
|
+
|
|
2691
|
+
# Handle results
|
|
2692
|
+
if not status:
|
|
2693
|
+
# Empty status means names matched (from_name == to_name)
|
|
2694
|
+
status = f"[SUCCESS] ✓ Already using alias {alias}"
|
|
2695
|
+
|
|
2696
|
+
# Print status and return
|
|
2697
|
+
print(status, file=sys.stderr)
|
|
2698
|
+
return 0 if status.startswith('[SUCCESS]') else 1
|
|
2699
|
+
|
|
2700
|
+
# ==================== Hook Helpers ====================
|
|
2640
2701
|
|
|
2641
2702
|
def format_hook_messages(messages, instance_name):
|
|
2642
2703
|
"""Format messages for hook feedback"""
|
|
@@ -2645,13 +2706,7 @@ def format_hook_messages(messages, instance_name):
|
|
|
2645
2706
|
reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
|
|
2646
2707
|
else:
|
|
2647
2708
|
parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
|
|
2648
|
-
reason = f"[{len(messages)} new messages] |
|
|
2649
|
-
|
|
2650
|
-
# Check alias announcement
|
|
2651
|
-
instance_data = load_instance_position(instance_name)
|
|
2652
|
-
if not instance_data.get('alias_announced', False) and not instance_name.endswith('claude'):
|
|
2653
|
-
reason = f"{reason} | [Alias assigned: {instance_name}] <Your hcom chat alias is {instance_name}. You can at-mention others in hcom chat by their alias to DM them. (alias1 → alias2 means alias1 sent the message to the entire group, if there is an at symbol in the message then it is targeted)>"
|
|
2654
|
-
update_instance_position(instance_name, {'alias_announced': True})
|
|
2709
|
+
reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
|
|
2655
2710
|
|
|
2656
2711
|
# Only append instance_hints to messages (first_use_text is handled separately)
|
|
2657
2712
|
instance_hints = get_config_value('instance_hints', '')
|
|
@@ -2660,149 +2715,76 @@ def format_hook_messages(messages, instance_name):
|
|
|
2660
2715
|
|
|
2661
2716
|
return reason
|
|
2662
2717
|
|
|
2663
|
-
def get_pending_tools(transcript_path, max_lines=100):
|
|
2664
|
-
"""Parse transcript to find tool_use IDs without matching tool_results.
|
|
2665
|
-
Returns count of pending tools."""
|
|
2666
|
-
if not transcript_path or not os.path.exists(transcript_path):
|
|
2667
|
-
return 0
|
|
2668
|
-
|
|
2669
|
-
tool_uses = set()
|
|
2670
|
-
tool_results = set()
|
|
2671
|
-
|
|
2672
|
-
try:
|
|
2673
|
-
# Read last N lines efficiently
|
|
2674
|
-
with open(transcript_path, 'rb') as f:
|
|
2675
|
-
# Seek to end and read backwards
|
|
2676
|
-
f.seek(0, 2) # Go to end
|
|
2677
|
-
file_size = f.tell()
|
|
2678
|
-
read_size = min(file_size, max_lines * 500) # Assume ~500 bytes per line
|
|
2679
|
-
f.seek(max(0, file_size - read_size))
|
|
2680
|
-
recent_content = f.read().decode('utf-8', errors='ignore')
|
|
2681
|
-
|
|
2682
|
-
# Parse line by line (handle both Unix \n and Windows \r\n)
|
|
2683
|
-
for line in recent_content.splitlines():
|
|
2684
|
-
if not line.strip():
|
|
2685
|
-
continue
|
|
2686
|
-
try:
|
|
2687
|
-
data = json.loads(line)
|
|
2688
|
-
|
|
2689
|
-
# Check for tool_use blocks in assistant messages
|
|
2690
|
-
if data.get('type') == 'assistant':
|
|
2691
|
-
content = data.get('message', {}).get('content', [])
|
|
2692
|
-
if isinstance(content, list):
|
|
2693
|
-
for item in content:
|
|
2694
|
-
if isinstance(item, dict) and item.get('type') == 'tool_use':
|
|
2695
|
-
tool_id = item.get('id')
|
|
2696
|
-
if tool_id:
|
|
2697
|
-
tool_uses.add(tool_id)
|
|
2698
|
-
|
|
2699
|
-
# Check for tool_results in user messages
|
|
2700
|
-
elif data.get('type') == 'user':
|
|
2701
|
-
content = data.get('message', {}).get('content', [])
|
|
2702
|
-
if isinstance(content, list):
|
|
2703
|
-
for item in content:
|
|
2704
|
-
if isinstance(item, dict) and item.get('type') == 'tool_result':
|
|
2705
|
-
tool_id = item.get('tool_use_id')
|
|
2706
|
-
if tool_id:
|
|
2707
|
-
tool_results.add(tool_id)
|
|
2708
|
-
except Exception as e:
|
|
2709
|
-
continue
|
|
2710
|
-
|
|
2711
|
-
# Return count of pending tools
|
|
2712
|
-
pending = tool_uses - tool_results
|
|
2713
|
-
return len(pending)
|
|
2714
|
-
except Exception as e:
|
|
2715
|
-
return 0 # On any error, assume no pending tools
|
|
2716
|
-
|
|
2717
2718
|
# ==================== Hook Handlers ====================
|
|
2718
2719
|
|
|
2719
|
-
def init_hook_context(hook_data):
|
|
2720
|
+
def init_hook_context(hook_data, hook_type=None):
|
|
2720
2721
|
"""Initialize instance context - shared by post/stop/notify hooks"""
|
|
2722
|
+
import time
|
|
2723
|
+
|
|
2721
2724
|
session_id = hook_data.get('session_id', '')
|
|
2722
2725
|
transcript_path = hook_data.get('transcript_path', '')
|
|
2723
2726
|
prefix = os.environ.get('HCOM_PREFIX')
|
|
2724
2727
|
|
|
2725
|
-
# Check if this is a resume operation
|
|
2726
|
-
resume_session_id = os.environ.get('HCOM_RESUME_SESSION_ID')
|
|
2727
2728
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2728
2729
|
instance_name = None
|
|
2729
2730
|
merged_state = None
|
|
2730
2731
|
|
|
2731
|
-
#
|
|
2732
|
-
|
|
2733
|
-
for instance_file in instances_dir.glob("*.json"):
|
|
2734
|
-
try:
|
|
2735
|
-
data = load_instance_position(instance_file.stem)
|
|
2736
|
-
# Check if resume_session_id matches any in the session_ids array
|
|
2737
|
-
old_session_ids = data.get('session_ids', [])
|
|
2738
|
-
if resume_session_id in old_session_ids:
|
|
2739
|
-
# Found the instance! Keep the same name
|
|
2740
|
-
instance_name = instance_file.stem
|
|
2741
|
-
merged_state = data
|
|
2742
|
-
# Append new session_id to array, update transcript_path to current
|
|
2743
|
-
if session_id and session_id not in old_session_ids:
|
|
2744
|
-
merged_state.setdefault('session_ids', old_session_ids).append(session_id)
|
|
2745
|
-
if transcript_path:
|
|
2746
|
-
merged_state['transcript_path'] = transcript_path
|
|
2747
|
-
break
|
|
2748
|
-
except:
|
|
2749
|
-
continue
|
|
2750
|
-
|
|
2751
|
-
# Check if current session exists in any instance's session_ids array
|
|
2752
|
-
# This maintains identity after implicit HCOM_RESUME
|
|
2732
|
+
# Check if current session_id matches any existing instance
|
|
2733
|
+
# This maintains identity after resume/merge operations
|
|
2753
2734
|
if not instance_name and session_id and instances_dir.exists():
|
|
2754
2735
|
for instance_file in instances_dir.glob("*.json"):
|
|
2755
2736
|
try:
|
|
2756
2737
|
data = load_instance_position(instance_file.stem)
|
|
2757
|
-
if session_id
|
|
2738
|
+
if session_id == data.get('session_id'):
|
|
2758
2739
|
instance_name = instance_file.stem
|
|
2759
2740
|
merged_state = data
|
|
2741
|
+
log_hook_error(f'DEBUG: Session_id {session_id[:8]} matched {instance_file.stem}, reusing that name')
|
|
2760
2742
|
break
|
|
2761
|
-
except:
|
|
2743
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
2762
2744
|
continue
|
|
2763
2745
|
|
|
2764
2746
|
# If not found or not resuming, generate new name from session_id
|
|
2765
2747
|
if not instance_name:
|
|
2766
2748
|
instance_name = get_display_name(session_id, prefix)
|
|
2749
|
+
# DEBUG: Log name generation
|
|
2750
|
+
log_hook_error(f'DEBUG: Generated instance_name={instance_name} from session_id={session_id[:8] if session_id else "None"}')
|
|
2767
2751
|
|
|
2768
|
-
#
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
# Found duplicate with same PID - merge and delete
|
|
2778
|
-
if not merged_state:
|
|
2779
|
-
merged_state = data
|
|
2780
|
-
else:
|
|
2781
|
-
# Merge useful fields from duplicate
|
|
2782
|
-
merged_state = merge_instance_data(merged_state, data)
|
|
2783
|
-
instance_file.unlink() # Delete the duplicate file
|
|
2784
|
-
# Don't break - could have multiple duplicates with same PID
|
|
2785
|
-
except:
|
|
2786
|
-
continue
|
|
2752
|
+
# Save launch_id → instance_name mapping for cmd_send()
|
|
2753
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2754
|
+
if launch_id:
|
|
2755
|
+
try:
|
|
2756
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}', ensure_parent=True)
|
|
2757
|
+
mapping_file.write_text(instance_name, encoding='utf-8')
|
|
2758
|
+
log_hook_error(f'DEBUG: FINAL - Wrote launch_map_{launch_id} → {instance_name} (session_id={session_id[:8] if session_id else "None"})')
|
|
2759
|
+
except Exception:
|
|
2760
|
+
pass # Non-critical
|
|
2787
2761
|
|
|
2788
2762
|
# Save migrated data if we have it
|
|
2789
2763
|
if merged_state:
|
|
2790
2764
|
save_instance_position(instance_name, merged_state)
|
|
2791
2765
|
|
|
2792
|
-
|
|
2793
|
-
|
|
2766
|
+
# Check if instance is brand new or pre-existing (before creation (WWJD))
|
|
2767
|
+
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
2768
|
+
is_new_instance = not instance_file.exists()
|
|
2794
2769
|
|
|
2795
|
-
#
|
|
2796
|
-
|
|
2770
|
+
# Skip instance creation for unmatched SessionStart resumes (prevents orphans)
|
|
2771
|
+
# Instance will be created in UserPromptSubmit with correct session_id
|
|
2772
|
+
should_create_instance = not (
|
|
2773
|
+
hook_type == 'sessionstart' and
|
|
2774
|
+
hook_data.get('source', 'startup') == 'resume' and not merged_state
|
|
2775
|
+
)
|
|
2776
|
+
if should_create_instance:
|
|
2777
|
+
initialize_instance_in_position_file(instance_name, session_id)
|
|
2778
|
+
existing_data = load_instance_position(instance_name) if should_create_instance else {}
|
|
2779
|
+
|
|
2780
|
+
# Prepare updates
|
|
2781
|
+
updates: dict[str, Any] = {
|
|
2797
2782
|
'directory': str(Path.cwd()),
|
|
2798
2783
|
}
|
|
2799
2784
|
|
|
2800
|
-
# Update
|
|
2785
|
+
# Update session_id (overwrites previous)
|
|
2801
2786
|
if session_id:
|
|
2802
|
-
|
|
2803
|
-
if session_id not in current_session_ids:
|
|
2804
|
-
current_session_ids.append(session_id)
|
|
2805
|
-
updates['session_ids'] = current_session_ids
|
|
2787
|
+
updates['session_id'] = session_id
|
|
2806
2788
|
|
|
2807
2789
|
# Update transcript_path to current
|
|
2808
2790
|
if transcript_path:
|
|
@@ -2817,276 +2799,205 @@ def init_hook_context(hook_data):
|
|
|
2817
2799
|
updates['background'] = True
|
|
2818
2800
|
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
2819
2801
|
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
"""Extract command payload with quote stripping"""
|
|
2824
|
-
marker = f'{prefix}:'
|
|
2825
|
-
if marker not in command:
|
|
2826
|
-
return None
|
|
2827
|
-
|
|
2828
|
-
parts = command.split(marker, 1)
|
|
2829
|
-
if len(parts) <= 1:
|
|
2830
|
-
return None
|
|
2831
|
-
|
|
2832
|
-
payload = parts[1].strip()
|
|
2833
|
-
|
|
2834
|
-
# Complex quote stripping logic (preserves exact behavior)
|
|
2835
|
-
if len(payload) >= 2 and \
|
|
2836
|
-
((payload[0] == '"' and payload[-1] == '"') or \
|
|
2837
|
-
(payload[0] == "'" and payload[-1] == "'")):
|
|
2838
|
-
payload = payload[1:-1]
|
|
2839
|
-
elif payload and payload[-1] in '"\'':
|
|
2840
|
-
payload = payload[:-1]
|
|
2841
|
-
|
|
2842
|
-
return payload if payload else None
|
|
2843
|
-
|
|
2844
|
-
def _sanitize_alias(alias):
|
|
2845
|
-
"""Sanitize extracted alias: strip quotes/backticks, stop at first invalid char/whitespace."""
|
|
2846
|
-
alias = alias.strip()
|
|
2847
|
-
# Strip wrapping quotes/backticks iteratively
|
|
2848
|
-
for _ in range(3):
|
|
2849
|
-
if len(alias) >= 2 and alias[0] == alias[-1] and alias[0] in ['"', "'", '`']:
|
|
2850
|
-
alias = alias[1:-1].strip()
|
|
2851
|
-
elif alias and alias[-1] in ['"', "'", '`']:
|
|
2852
|
-
alias = alias[:-1].strip()
|
|
2853
|
-
else:
|
|
2854
|
-
break
|
|
2855
|
-
# Stop at first whitespace or invalid char
|
|
2856
|
-
alias = re.split(r'[^A-Za-z0-9\-_]', alias)[0]
|
|
2857
|
-
return alias
|
|
2858
|
-
|
|
2859
|
-
def extract_resume_alias(command):
|
|
2860
|
-
"""Extract resume alias safely.
|
|
2861
|
-
Priority:
|
|
2862
|
-
1) HCOM_SEND payload that starts with RESUME:alias
|
|
2863
|
-
2) Bare HCOM_RESUME:alias (only when not embedded in HCOM_SEND payload)
|
|
2864
|
-
"""
|
|
2865
|
-
# 1) Prefer explicit HCOM_SEND payload
|
|
2866
|
-
payload = extract_hcom_command(command)
|
|
2867
|
-
if payload:
|
|
2868
|
-
cand = payload.strip()
|
|
2869
|
-
if cand.startswith('RESUME:'):
|
|
2870
|
-
alias_raw = cand.split(':', 1)[1].strip()
|
|
2871
|
-
alias = _sanitize_alias(alias_raw)
|
|
2872
|
-
return alias or None
|
|
2873
|
-
# If payload contains text like "HCOM_RESUME:alias" but not at start,
|
|
2874
|
-
# ignore to prevent alias hijack from normal messages
|
|
2875
|
-
|
|
2876
|
-
# 2) Fallback: bare HCOM_RESUME when not using HCOM_SEND
|
|
2877
|
-
alias_raw = extract_hcom_command(command, 'HCOM_RESUME')
|
|
2878
|
-
if alias_raw:
|
|
2879
|
-
alias = _sanitize_alias(alias_raw)
|
|
2880
|
-
return alias or None
|
|
2881
|
-
return None
|
|
2882
|
-
|
|
2883
|
-
def compute_decision_for_visibility(transcript_path):
|
|
2884
|
-
"""Compute hook decision based on pending tools to prevent API 400 errors."""
|
|
2885
|
-
pending_tools = get_pending_tools(transcript_path)
|
|
2886
|
-
decision = None if pending_tools > 0 else HOOK_DECISION_BLOCK
|
|
2887
|
-
|
|
2888
|
-
return decision
|
|
2802
|
+
# Return flags indicating resume state
|
|
2803
|
+
is_resume_match = merged_state is not None
|
|
2804
|
+
return instance_name, updates, existing_data, is_resume_match, is_new_instance
|
|
2889
2805
|
|
|
2890
|
-
def
|
|
2891
|
-
"""
|
|
2892
|
-
|
|
2893
|
-
if status.startswith("[SUCCESS]"):
|
|
2894
|
-
reason = f"[{status}]{HCOM_FORMAT_INSTRUCTIONS}"
|
|
2895
|
-
else:
|
|
2896
|
-
reason = f"[⚠️ {status} - your alias is: {instance_name}]{HCOM_FORMAT_INSTRUCTIONS}"
|
|
2806
|
+
def handle_pretooluse(hook_data, instance_name, updates):
|
|
2807
|
+
"""Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
|
|
2808
|
+
tool_name = hook_data.get('tool_name', '')
|
|
2897
2809
|
|
|
2898
|
-
#
|
|
2899
|
-
|
|
2810
|
+
# Non-HCOM_SEND tools: record status (they'll run without permission check)
|
|
2811
|
+
set_status(instance_name, 'tool_pending', tool_name)
|
|
2900
2812
|
|
|
2901
|
-
|
|
2902
|
-
emit_hook_response(reason, decision=decision)
|
|
2813
|
+
import time
|
|
2903
2814
|
|
|
2904
|
-
|
|
2905
|
-
"""Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
|
|
2906
|
-
# Check if this is an HCOM_SEND command that needs auto-approval
|
|
2907
|
-
tool_name = hook_data.get('tool_name', '')
|
|
2815
|
+
# Handle HCOM commands in Bash
|
|
2908
2816
|
if tool_name == 'Bash':
|
|
2909
2817
|
command = hook_data.get('tool_input', {}).get('command', '')
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
}
|
|
2925
|
-
else:
|
|
2926
|
-
# Safe to proceed
|
|
2927
|
-
output = {
|
|
2928
|
-
"hookSpecificOutput": {
|
|
2929
|
-
"hookEventName": "PreToolUse",
|
|
2930
|
-
"permissionDecision": "allow",
|
|
2931
|
-
"permissionDecisionReason": "HCOM_SEND command auto-approved"
|
|
2932
|
-
}
|
|
2818
|
+
script_path = str(Path(__file__).resolve())
|
|
2819
|
+
|
|
2820
|
+
# === Auto-approve ALL '$HCOM send' commands (including --resume) ===
|
|
2821
|
+
# This includes:
|
|
2822
|
+
# - $HCOM send "message" (normal messaging between instances)
|
|
2823
|
+
# - $HCOM send --resume alias (resume/merge operation)
|
|
2824
|
+
if ('$HCOM send' in command or
|
|
2825
|
+
'hcom send' in command or
|
|
2826
|
+
(script_path in command and ' send ' in command)):
|
|
2827
|
+
output = {
|
|
2828
|
+
"hookSpecificOutput": {
|
|
2829
|
+
"hookEventName": "PreToolUse",
|
|
2830
|
+
"permissionDecision": "allow",
|
|
2831
|
+
"permissionDecisionReason": "HCOM send command auto-approved"
|
|
2933
2832
|
}
|
|
2833
|
+
}
|
|
2934
2834
|
print(json.dumps(output, ensure_ascii=False))
|
|
2935
2835
|
sys.exit(EXIT_SUCCESS)
|
|
2936
2836
|
|
|
2937
|
-
def handle_posttooluse(hook_data, instance_name, updates):
|
|
2938
|
-
"""Handle PostToolUse hook - extract and deliver messages"""
|
|
2939
|
-
updates['last_tool'] = int(time.time())
|
|
2940
|
-
updates['last_tool_name'] = hook_data.get('tool_name', 'unknown')
|
|
2941
|
-
update_instance_position(instance_name, updates)
|
|
2942
|
-
|
|
2943
|
-
# Check for HCOM_SEND in Bash commands
|
|
2944
|
-
sent_reason = None
|
|
2945
|
-
if hook_data.get('tool_name') == 'Bash':
|
|
2946
|
-
command = hook_data.get('tool_input', {}).get('command', '')
|
|
2947
2837
|
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
if alias:
|
|
2951
|
-
status = merge_instance_immediately(instance_name, alias)
|
|
2952
|
-
|
|
2953
|
-
# If names match, find and merge any duplicate with same PID
|
|
2954
|
-
if not status and instance_name == alias:
|
|
2955
|
-
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2956
|
-
parent_pid = os.getppid()
|
|
2957
|
-
if instances_dir.exists():
|
|
2958
|
-
for instance_file in instances_dir.glob("*.json"):
|
|
2959
|
-
if instance_file.stem != instance_name:
|
|
2960
|
-
try:
|
|
2961
|
-
data = load_instance_position(instance_file.stem)
|
|
2962
|
-
if data.get('pid') == parent_pid:
|
|
2963
|
-
# Found duplicate - merge it
|
|
2964
|
-
status = merge_instance_immediately(instance_file.stem, instance_name)
|
|
2965
|
-
if status:
|
|
2966
|
-
status = f"[SUCCESS] ✓ Merged duplicate: {instance_file.stem} → {instance_name}"
|
|
2967
|
-
break
|
|
2968
|
-
except:
|
|
2969
|
-
continue
|
|
2970
|
-
|
|
2971
|
-
if not status:
|
|
2972
|
-
status = f"[SUCCESS] ✓ Already using alias {alias}"
|
|
2973
|
-
elif not status:
|
|
2974
|
-
status = f"[WARNING] ⚠️ Merge failed: {instance_name} → {alias}"
|
|
2975
|
-
|
|
2976
|
-
if status:
|
|
2977
|
-
transcript_path = hook_data.get('transcript_path', '')
|
|
2978
|
-
emit_resume_feedback(status, instance_name, transcript_path)
|
|
2979
|
-
return # Don't process RESUME as regular message
|
|
2980
|
-
|
|
2981
|
-
# Normal message handling
|
|
2982
|
-
message = extract_hcom_command(command) # defaults to HCOM_SEND
|
|
2983
|
-
if message:
|
|
2984
|
-
error = validate_message(message)
|
|
2985
|
-
if error:
|
|
2986
|
-
emit_hook_response(f"❌ {error}")
|
|
2987
|
-
send_message(instance_name, message)
|
|
2988
|
-
sent_reason = "[✓ Sent]"
|
|
2989
|
-
|
|
2990
|
-
# Check for pending tools in transcript
|
|
2991
|
-
transcript_path = hook_data.get('transcript_path', '')
|
|
2992
|
-
pending_count = get_pending_tools(transcript_path)
|
|
2993
|
-
|
|
2994
|
-
# Build response if needed
|
|
2995
|
-
response_reason = None
|
|
2996
|
-
|
|
2997
|
-
# Only deliver messages when all tools are complete (pending_count == 0)
|
|
2998
|
-
if pending_count == 0:
|
|
2999
|
-
messages = get_new_messages(instance_name)
|
|
3000
|
-
if messages:
|
|
3001
|
-
messages = messages[:get_config_value('max_messages_per_delivery', 50)]
|
|
3002
|
-
reason = format_hook_messages(messages, instance_name)
|
|
3003
|
-
response_reason = f"{sent_reason} | {reason}" if sent_reason else reason
|
|
3004
|
-
elif sent_reason:
|
|
3005
|
-
response_reason = sent_reason
|
|
3006
|
-
elif sent_reason:
|
|
3007
|
-
# Tools still pending - acknowledge HCOM_SEND without disrupting tool batching
|
|
3008
|
-
response_reason = sent_reason
|
|
3009
|
-
|
|
3010
|
-
# Emit response with formatting if we have anything to say
|
|
3011
|
-
if response_reason:
|
|
3012
|
-
response_reason += HCOM_FORMAT_INSTRUCTIONS
|
|
3013
|
-
# CRITICAL: decision=None when tools are pending to prevent API 400 errors
|
|
3014
|
-
decision = compute_decision_for_visibility(transcript_path)
|
|
3015
|
-
emit_hook_response(response_reason, decision=decision)
|
|
3016
|
-
|
|
3017
|
-
def handle_stop(instance_name, updates):
|
|
3018
|
-
"""Handle Stop hook - poll for messages"""
|
|
3019
|
-
updates['last_stop'] = time.time()
|
|
3020
|
-
timeout = get_config_value('wait_timeout', 1800)
|
|
3021
|
-
updates['wait_timeout'] = timeout
|
|
3022
|
-
|
|
3023
|
-
# Try to update position, but continue on Windows file locking errors
|
|
2838
|
+
def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
|
|
2839
|
+
"""Safely exit stop hook with proper status tracking"""
|
|
3024
2840
|
try:
|
|
3025
|
-
|
|
3026
|
-
except
|
|
3027
|
-
# Silently handle
|
|
3028
|
-
|
|
2841
|
+
set_status(instance_name, 'stop_exit')
|
|
2842
|
+
except (OSError, PermissionError):
|
|
2843
|
+
pass # Silently handle any errors
|
|
2844
|
+
sys.exit(code)
|
|
3029
2845
|
|
|
3030
|
-
|
|
3031
|
-
|
|
2846
|
+
def handle_stop(hook_data, instance_name, updates):
|
|
2847
|
+
"""Handle Stop hook - poll for messages and deliver"""
|
|
2848
|
+
import time as time_module
|
|
3032
2849
|
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
loop_count += 1
|
|
3037
|
-
current_time = time.time()
|
|
2850
|
+
parent_pid = os.getppid()
|
|
2851
|
+
log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
|
|
2852
|
+
log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
|
|
3038
2853
|
|
|
3039
|
-
# Unix/Mac: Check if orphaned (reparented to PID 1)
|
|
3040
|
-
if not IS_WINDOWS and os.getppid() == 1:
|
|
3041
|
-
sys.exit(EXIT_SUCCESS)
|
|
3042
2854
|
|
|
3043
|
-
|
|
3044
|
-
|
|
2855
|
+
try:
|
|
2856
|
+
entry_time = time_module.time()
|
|
2857
|
+
updates['last_stop'] = entry_time
|
|
2858
|
+
timeout = get_config_value('wait_timeout', 1800)
|
|
2859
|
+
updates['wait_timeout'] = timeout
|
|
2860
|
+
set_status(instance_name, 'waiting')
|
|
3045
2861
|
|
|
3046
|
-
|
|
3047
|
-
|
|
2862
|
+
try:
|
|
2863
|
+
update_instance_position(instance_name, updates)
|
|
2864
|
+
except Exception as e:
|
|
2865
|
+
log_hook_error(f'stop:update_instance_position({instance_name})', e)
|
|
3048
2866
|
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
pending_count = get_pending_tools(transcript_path)
|
|
2867
|
+
start_time = time_module.time()
|
|
2868
|
+
log_hook_error(f'stop:start_time_pid_{os.getpid()}')
|
|
3052
2869
|
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
2870
|
+
try:
|
|
2871
|
+
loop_count = 0
|
|
2872
|
+
# STEP 4: Actual polling loop - this IS the holding pattern
|
|
2873
|
+
while time_module.time() - start_time < timeout:
|
|
2874
|
+
if loop_count == 0:
|
|
2875
|
+
time_module.sleep(0.1) # Initial wait before first poll
|
|
2876
|
+
loop_count += 1
|
|
2877
|
+
|
|
2878
|
+
# Check if parent is alive
|
|
2879
|
+
if not IS_WINDOWS and os.getppid() == 1:
|
|
2880
|
+
log_hook_error(f'stop:parent_died_pid_{os.getpid()}')
|
|
2881
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2882
|
+
|
|
2883
|
+
parent_alive = is_parent_alive(parent_pid)
|
|
2884
|
+
if not parent_alive:
|
|
2885
|
+
log_hook_error(f'stop:parent_not_alive_pid_{os.getpid()}')
|
|
2886
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2887
|
+
|
|
2888
|
+
# Check if user input is pending - exit cleanly if so
|
|
2889
|
+
user_input_signal = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
|
|
2890
|
+
if user_input_signal.exists():
|
|
2891
|
+
log_hook_error(f'stop:user_input_pending_exiting_pid_{os.getpid()}')
|
|
2892
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2893
|
+
|
|
2894
|
+
# Check for new messages and deliver
|
|
2895
|
+
if messages := get_unread_messages(instance_name, update_position=True):
|
|
3057
2896
|
messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
|
|
3058
2897
|
reason = format_hook_messages(messages_to_show, instance_name)
|
|
3059
|
-
|
|
2898
|
+
set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
|
|
3060
2899
|
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
except Exception as e:
|
|
3066
|
-
# Silently handle file locking exceptions on Windows and continue polling
|
|
3067
|
-
pass
|
|
2900
|
+
log_hook_error(f'stop:delivering_message_pid_{os.getpid()}')
|
|
2901
|
+
output = {"decision": "block", "reason": reason}
|
|
2902
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
2903
|
+
sys.exit(EXIT_BLOCK)
|
|
3068
2904
|
|
|
3069
|
-
|
|
2905
|
+
# Update heartbeat
|
|
2906
|
+
try:
|
|
2907
|
+
update_instance_position(instance_name, {'last_stop': time_module.time()})
|
|
2908
|
+
# log_hook_error(f'hb_pid_{os.getpid()}')
|
|
2909
|
+
except Exception as e:
|
|
2910
|
+
log_hook_error(f'stop:heartbeat_update({instance_name})', e)
|
|
2911
|
+
|
|
2912
|
+
time_module.sleep(STOP_HOOK_POLL_INTERVAL)
|
|
2913
|
+
|
|
2914
|
+
except Exception as loop_e:
|
|
2915
|
+
# Log polling loop errors but continue to cleanup
|
|
2916
|
+
log_hook_error(f'stop:polling_loop({instance_name})', loop_e)
|
|
2917
|
+
|
|
2918
|
+
# Timeout reached
|
|
2919
|
+
set_status(instance_name, 'timeout')
|
|
3070
2920
|
|
|
3071
2921
|
except Exception as e:
|
|
3072
|
-
#
|
|
3073
|
-
|
|
2922
|
+
# Log error and exit gracefully
|
|
2923
|
+
log_hook_error('handle_stop', e)
|
|
2924
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
3074
2925
|
|
|
3075
2926
|
def handle_notify(hook_data, instance_name, updates):
|
|
3076
2927
|
"""Handle Notification hook - track permission requests"""
|
|
3077
|
-
updates['last_permission_request'] = int(time.time())
|
|
3078
2928
|
updates['notification_message'] = hook_data.get('message', '')
|
|
3079
2929
|
update_instance_position(instance_name, updates)
|
|
2930
|
+
set_status(instance_name, 'blocked', hook_data.get('message', ''))
|
|
2931
|
+
|
|
2932
|
+
def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
|
|
2933
|
+
"""Handle UserPromptSubmit hook - track when user sends messages"""
|
|
2934
|
+
import time as time_module
|
|
2935
|
+
|
|
2936
|
+
# Update last user input timestamp
|
|
2937
|
+
updates['last_user_input'] = time_module.time()
|
|
2938
|
+
update_instance_position(instance_name, updates)
|
|
3080
2939
|
|
|
3081
|
-
|
|
2940
|
+
# Signal any polling Stop hook to exit cleanly before user input processed
|
|
2941
|
+
signal_file = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
|
|
2942
|
+
try:
|
|
2943
|
+
log_hook_error(f'userpromptsubmit:signal_file_touched_pid_{os.getpid()}')
|
|
2944
|
+
signal_file.touch()
|
|
2945
|
+
time_module.sleep(0.15) # Give Stop hook time to detect and exit
|
|
2946
|
+
log_hook_error(f'userpromptsubmit:signal_file_unlinked_pid_{os.getpid()}')
|
|
2947
|
+
signal_file.unlink()
|
|
2948
|
+
except (OSError, PermissionError) as e:
|
|
2949
|
+
log_hook_error(f'userpromptsubmit:signal_file_error', e)
|
|
2950
|
+
|
|
2951
|
+
send_cmd = build_send_command('your message')
|
|
2952
|
+
resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
|
|
2953
|
+
# Build message based on what happened
|
|
2954
|
+
msg = None
|
|
2955
|
+
if is_resume_match:
|
|
2956
|
+
msg = f"[Resumed with hcom chat alias: {instance_name}. Use {send_cmd} to send messages]"
|
|
2957
|
+
elif is_new_instance:
|
|
2958
|
+
# Unmatched resume - show critical recovery message
|
|
2959
|
+
msg = (
|
|
2960
|
+
f"[HCOM RESUME DETECTED - CRITICAL ACTION REQUIRED "
|
|
2961
|
+
f"You MUST recover your HCOM identity to maintain conversation context "
|
|
2962
|
+
f"Run: {resume_cmd} "
|
|
2963
|
+
f"This is REQUIRED for message history and position tracking]"
|
|
2964
|
+
)
|
|
2965
|
+
else:
|
|
2966
|
+
# Check if we need to announce alias (normal startup)
|
|
2967
|
+
instance_data = load_instance_position(instance_name)
|
|
2968
|
+
alias_announced = instance_data.get('alias_announced', False)
|
|
2969
|
+
if not alias_announced:
|
|
2970
|
+
msg = f"[Your hcom chat alias is {instance_name}. You can at-mention others in hcom chat by their alias to DM them. To send a message use: {send_cmd}]"
|
|
2971
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
2972
|
+
|
|
2973
|
+
if msg:
|
|
2974
|
+
output = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": msg}}
|
|
2975
|
+
print(json.dumps(output))
|
|
2976
|
+
|
|
2977
|
+
def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
|
|
3082
2978
|
"""Handle SessionStart hook - deliver welcome/resume message"""
|
|
3083
2979
|
source = hook_data.get('source', 'startup')
|
|
3084
2980
|
|
|
2981
|
+
log_hook_error(f'sessionstart:is_resume_match_{is_resume_match}')
|
|
2982
|
+
log_hook_error(f'sessionstart:instance_name_{instance_name}')
|
|
2983
|
+
log_hook_error(f'sessionstart:source_{source}')
|
|
2984
|
+
log_hook_error(f'sessionstart:updates_{updates}')
|
|
2985
|
+
log_hook_error(f'sessionstart:hook_data_{hook_data}')
|
|
2986
|
+
|
|
3085
2987
|
# Reset alias_announced flag so alias shows again on resume/clear/compact
|
|
3086
2988
|
updates['alias_announced'] = False
|
|
3087
2989
|
|
|
3088
|
-
#
|
|
3089
|
-
|
|
2990
|
+
# Only update instance position if file exists (startup or matched resume)
|
|
2991
|
+
# For unmatched resumes, skip - UserPromptSubmit will create the file with correct session_id
|
|
2992
|
+
if source == 'startup' or is_resume_match:
|
|
2993
|
+
update_instance_position(instance_name, updates)
|
|
2994
|
+
set_status(instance_name, 'session_start')
|
|
2995
|
+
|
|
2996
|
+
log_hook_error(f'sessionstart:instance_name_after_update_{instance_name}')
|
|
2997
|
+
|
|
2998
|
+
# Build send command using helper
|
|
2999
|
+
send_cmd = build_send_command('your message')
|
|
3000
|
+
help_text = f"[Welcome! HCOM chat active. Send messages: {send_cmd}]"
|
|
3090
3001
|
|
|
3091
3002
|
# Add subagent type if this is a named agent
|
|
3092
3003
|
subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
|
|
@@ -3099,11 +3010,10 @@ def handle_sessionstart(hook_data, instance_name, updates):
|
|
|
3099
3010
|
if first_use_text:
|
|
3100
3011
|
help_text += f" [{first_use_text}]"
|
|
3101
3012
|
elif source == 'resume':
|
|
3102
|
-
if
|
|
3103
|
-
|
|
3104
|
-
help_text += f" [⚠️ Resume detected - temp: {instance_name}. If you had a previous HCOM alias, run: echo \"HCOM_RESUME:your_alias\"]"
|
|
3013
|
+
if is_resume_match:
|
|
3014
|
+
help_text += f" [Resumed alias: {instance_name}]"
|
|
3105
3015
|
else:
|
|
3106
|
-
help_text += " [
|
|
3016
|
+
help_text += f" [Session resumed]"
|
|
3107
3017
|
|
|
3108
3018
|
# Add instance hints to all messages
|
|
3109
3019
|
instance_hints = get_config_value('instance_hints', '')
|
|
@@ -3119,37 +3029,34 @@ def handle_sessionstart(hook_data, instance_name, updates):
|
|
|
3119
3029
|
}
|
|
3120
3030
|
print(json.dumps(output))
|
|
3121
3031
|
|
|
3122
|
-
|
|
3123
|
-
update_instance_position(instance_name, updates)
|
|
3124
|
-
|
|
3125
|
-
def handle_hook(hook_type):
|
|
3032
|
+
def handle_hook(hook_type: str) -> None:
|
|
3126
3033
|
"""Unified hook handler for all HCOM hooks"""
|
|
3127
3034
|
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
3128
3035
|
sys.exit(EXIT_SUCCESS)
|
|
3129
3036
|
|
|
3130
|
-
|
|
3131
|
-
|
|
3037
|
+
hook_data = json.load(sys.stdin)
|
|
3038
|
+
log_hook_error(f'handle_hook:hook_data_{hook_data}')
|
|
3132
3039
|
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3040
|
+
# DEBUG: Log which hook is being called with which session_id
|
|
3041
|
+
session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
|
|
3042
|
+
log_hook_error(f'DEBUG: Hook {hook_type} called with session_id={session_id_short}')
|
|
3043
|
+
|
|
3044
|
+
instance_name, updates, _, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
|
|
3045
|
+
|
|
3046
|
+
match hook_type:
|
|
3047
|
+
case 'pre':
|
|
3048
|
+
handle_pretooluse(hook_data, instance_name, updates)
|
|
3049
|
+
case 'stop':
|
|
3050
|
+
handle_stop(hook_data, instance_name, updates)
|
|
3051
|
+
case 'notify':
|
|
3052
|
+
handle_notify(hook_data, instance_name, updates)
|
|
3053
|
+
case 'userpromptsubmit':
|
|
3054
|
+
handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
|
|
3055
|
+
case 'sessionstart':
|
|
3056
|
+
handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
|
|
3057
|
+
|
|
3058
|
+
log_hook_error(f'handle_hook:instance_name_{instance_name}')
|
|
3150
3059
|
|
|
3151
|
-
except Exception:
|
|
3152
|
-
pass
|
|
3153
3060
|
|
|
3154
3061
|
sys.exit(EXIT_SUCCESS)
|
|
3155
3062
|
|
|
@@ -3166,34 +3073,41 @@ def main(argv=None):
|
|
|
3166
3073
|
|
|
3167
3074
|
cmd = argv[1]
|
|
3168
3075
|
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3076
|
+
match cmd:
|
|
3077
|
+
case 'help' | '--help':
|
|
3078
|
+
return cmd_help()
|
|
3079
|
+
case 'open':
|
|
3080
|
+
return cmd_open(*argv[2:])
|
|
3081
|
+
case 'watch':
|
|
3082
|
+
return cmd_watch(*argv[2:])
|
|
3083
|
+
case 'clear':
|
|
3084
|
+
return cmd_clear()
|
|
3085
|
+
case 'cleanup':
|
|
3086
|
+
return cmd_cleanup(*argv[2:])
|
|
3087
|
+
case 'send':
|
|
3088
|
+
if len(argv) < 3:
|
|
3089
|
+
print(format_error("Message required"), file=sys.stderr)
|
|
3090
|
+
return 1
|
|
3091
|
+
|
|
3092
|
+
# HIDDEN COMMAND: --resume is only used internally by instances during resume workflow
|
|
3093
|
+
# Not meant for regular CLI usage. Primary usage:
|
|
3094
|
+
# - From instances: $HCOM send "message" (instances send messages to each other)
|
|
3095
|
+
# - From CLI: hcom send "message" (user/claude orchestrator sends to instances)
|
|
3096
|
+
if argv[2] == '--resume':
|
|
3097
|
+
if len(argv) < 4:
|
|
3098
|
+
print(format_error("Alias required for --resume"), file=sys.stderr)
|
|
3099
|
+
return 1
|
|
3100
|
+
return cmd_resume_merge(argv[3])
|
|
3101
|
+
|
|
3102
|
+
return cmd_send(argv[2])
|
|
3103
|
+
case 'kill':
|
|
3104
|
+
return cmd_kill(*argv[2:])
|
|
3105
|
+
case 'stop' | 'notify' | 'pre' | 'sessionstart' | 'userpromptsubmit':
|
|
3106
|
+
handle_hook(cmd)
|
|
3107
|
+
return 0
|
|
3108
|
+
case _:
|
|
3109
|
+
print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
|
|
3183
3110
|
return 1
|
|
3184
|
-
return cmd_send(argv[2])
|
|
3185
|
-
elif cmd == 'kill':
|
|
3186
|
-
return cmd_kill(*argv[2:])
|
|
3187
|
-
|
|
3188
|
-
# Hook commands
|
|
3189
|
-
elif cmd in ['post', 'stop', 'notify', 'pre', 'sessionstart']:
|
|
3190
|
-
handle_hook(cmd)
|
|
3191
|
-
return 0
|
|
3192
|
-
|
|
3193
|
-
# Unknown command
|
|
3194
|
-
else:
|
|
3195
|
-
print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
|
|
3196
|
-
return 1
|
|
3197
3111
|
|
|
3198
3112
|
if __name__ == '__main__':
|
|
3199
3113
|
sys.exit(main())
|