hcom 0.3.1__py3-none-any.whl → 0.4.2.post3__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 +1781 -1132
- {hcom-0.3.1.dist-info → hcom-0.4.2.post3.dist-info}/METADATA +152 -149
- hcom-0.4.2.post3.dist-info/RECORD +7 -0
- hcom-0.3.1.dist-info/RECORD +0 -7
- {hcom-0.3.1.dist-info → hcom-0.4.2.post3.dist-info}/WHEEL +0 -0
- {hcom-0.3.1.dist-info → hcom-0.4.2.post3.dist-info}/entry_points.txt +0 -0
- {hcom-0.3.1.dist-info → hcom-0.4.2.post3.dist-info}/top_level.txt +0 -0
hcom/__main__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
hcom
|
|
3
|
+
hcom
|
|
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
|
|
|
@@ -19,18 +19,20 @@ import platform
|
|
|
19
19
|
import random
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from datetime import datetime, timedelta
|
|
22
|
-
from typing import
|
|
23
|
-
from dataclasses import dataclass
|
|
22
|
+
from typing import Any, NamedTuple
|
|
23
|
+
from dataclasses import dataclass
|
|
24
24
|
|
|
25
25
|
if sys.version_info < (3, 10):
|
|
26
26
|
sys.exit("Error: hcom requires Python 3.10 or higher")
|
|
27
27
|
|
|
28
|
+
__version__ = "0.5.0"
|
|
29
|
+
|
|
28
30
|
# ==================== Constants ====================
|
|
29
31
|
|
|
30
32
|
IS_WINDOWS = sys.platform == 'win32'
|
|
31
33
|
|
|
32
|
-
def is_wsl():
|
|
33
|
-
"""Detect if running in WSL
|
|
34
|
+
def is_wsl() -> bool:
|
|
35
|
+
"""Detect if running in WSL"""
|
|
34
36
|
if platform.system() != 'Linux':
|
|
35
37
|
return False
|
|
36
38
|
try:
|
|
@@ -39,7 +41,7 @@ def is_wsl():
|
|
|
39
41
|
except (FileNotFoundError, PermissionError, OSError):
|
|
40
42
|
return False
|
|
41
43
|
|
|
42
|
-
def is_termux():
|
|
44
|
+
def is_termux() -> bool:
|
|
43
45
|
"""Detect if running in Termux on Android"""
|
|
44
46
|
return (
|
|
45
47
|
'TERMUX_VERSION' in os.environ or # Primary: Works all versions
|
|
@@ -48,51 +50,15 @@ def is_termux():
|
|
|
48
50
|
'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
|
|
49
51
|
)
|
|
50
52
|
|
|
51
|
-
HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
|
|
52
|
-
HCOM_ACTIVE_VALUE = '1'
|
|
53
|
-
|
|
54
53
|
EXIT_SUCCESS = 0
|
|
55
54
|
EXIT_BLOCK = 2
|
|
56
55
|
|
|
57
|
-
ERROR_ACCESS_DENIED = 5 # Windows - Process exists but no permission
|
|
58
|
-
ERROR_INVALID_PARAMETER = 87 # Windows - Invalid PID or parameters
|
|
59
|
-
|
|
60
56
|
# Windows API constants
|
|
61
57
|
CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
|
|
62
|
-
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 # Vista+ minimal access rights
|
|
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!
|
|
64
58
|
|
|
65
59
|
# Timing constants
|
|
66
60
|
FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
|
|
67
61
|
STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
|
|
68
|
-
KILL_CHECK_INTERVAL = 0.1 # 100ms between process termination checks
|
|
69
|
-
MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
|
|
70
|
-
|
|
71
|
-
# Windows kernel32 cache
|
|
72
|
-
_windows_kernel32_cache = None
|
|
73
|
-
|
|
74
|
-
def get_windows_kernel32():
|
|
75
|
-
"""Get cached Windows kernel32 with function signatures configured.
|
|
76
|
-
This eliminates repeated initialization in hot code paths (e.g., stop hook polling).
|
|
77
|
-
"""
|
|
78
|
-
global _windows_kernel32_cache
|
|
79
|
-
if _windows_kernel32_cache is None and IS_WINDOWS:
|
|
80
|
-
import ctypes
|
|
81
|
-
import ctypes.wintypes
|
|
82
|
-
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
83
|
-
|
|
84
|
-
# Set proper ctypes function signatures to avoid ERROR_INVALID_PARAMETER
|
|
85
|
-
kernel32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
|
|
86
|
-
kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
|
|
87
|
-
kernel32.GetLastError.argtypes = []
|
|
88
|
-
kernel32.GetLastError.restype = ctypes.wintypes.DWORD
|
|
89
|
-
kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
|
|
90
|
-
kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
|
|
91
|
-
kernel32.GetExitCodeProcess.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(ctypes.wintypes.DWORD)]
|
|
92
|
-
kernel32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL
|
|
93
|
-
|
|
94
|
-
_windows_kernel32_cache = kernel32
|
|
95
|
-
return _windows_kernel32_cache
|
|
96
62
|
|
|
97
63
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
98
64
|
AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
|
|
@@ -127,9 +93,11 @@ STATUS_INFO = {
|
|
|
127
93
|
'tool_pending': ('active', '{} executing'),
|
|
128
94
|
'waiting': ('waiting', 'idle'),
|
|
129
95
|
'message_delivered': ('delivered', 'msg from {}'),
|
|
130
|
-
'stop_exit': ('inactive', 'stopped'),
|
|
131
96
|
'timeout': ('inactive', 'timeout'),
|
|
132
|
-
'
|
|
97
|
+
'stopped': ('inactive', 'stopped'),
|
|
98
|
+
'force_stopped': ('inactive', 'force stopped'),
|
|
99
|
+
'started': ('active', 'starting'),
|
|
100
|
+
'session_ended': ('inactive', 'ended: {}'),
|
|
133
101
|
'blocked': ('blocked', '{} blocked'),
|
|
134
102
|
'unknown': ('unknown', 'unknown'),
|
|
135
103
|
}
|
|
@@ -147,15 +115,70 @@ if IS_WINDOWS or is_wsl():
|
|
|
147
115
|
# ==================== Error Handling Strategy ====================
|
|
148
116
|
# Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
|
|
149
117
|
# CLI: Can raise exceptions for user feedback. Check return values.
|
|
150
|
-
# Critical I/O: atomic_write, save_instance_position
|
|
118
|
+
# Critical I/O: atomic_write, save_instance_position
|
|
151
119
|
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
152
120
|
|
|
153
|
-
|
|
121
|
+
# ==================== CLI Errors ====================
|
|
122
|
+
|
|
123
|
+
class CLIError(Exception):
|
|
124
|
+
"""Raised when arguments cannot be mapped to command semantics."""
|
|
125
|
+
|
|
126
|
+
# ==================== Help Text ====================
|
|
127
|
+
|
|
128
|
+
HELP_TEXT = """hcom - Claude Hook Comms
|
|
129
|
+
|
|
130
|
+
Usage: [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
|
|
131
|
+
hcom watch [--logs|--status|--wait [SEC]]
|
|
132
|
+
hcom send "message"
|
|
133
|
+
hcom stop [alias|all] [--force]
|
|
134
|
+
hcom start [alias]
|
|
135
|
+
hcom reset [logs|hooks|config]
|
|
136
|
+
|
|
137
|
+
Launch Examples:
|
|
138
|
+
hcom 3 Open 3 terminals with claude connected to hcom
|
|
139
|
+
hcom 3 claude -p + Background/headless
|
|
140
|
+
HCOM_TAG=api hcom 3 claude -p + @-mention group tag
|
|
141
|
+
|
|
142
|
+
Commands:
|
|
143
|
+
watch Interactive messaging/status dashboard
|
|
144
|
+
--logs Print all messages
|
|
145
|
+
--status Print instance status JSON
|
|
146
|
+
--wait [SEC] Wait and notify for new message
|
|
147
|
+
|
|
148
|
+
send "msg" Send message to all instances
|
|
149
|
+
send "@alias msg" Send to specific instance/group
|
|
150
|
+
|
|
151
|
+
stop Stop current instance (from inside Claude)
|
|
152
|
+
stop <alias> Stop specific instance
|
|
153
|
+
stop all Stop all instances
|
|
154
|
+
--force Emergency stop (denies Bash tool)
|
|
155
|
+
|
|
156
|
+
start Start current instance (from inside Claude)
|
|
157
|
+
start <alias> Start specific instance
|
|
158
|
+
|
|
159
|
+
reset Stop all + archive logs + remove hooks + clear config
|
|
160
|
+
reset logs Clear + archive conversation log
|
|
161
|
+
reset hooks Safely remove hcom hooks from claude settings.json
|
|
162
|
+
reset config Clear + backup config.env
|
|
163
|
+
|
|
164
|
+
Environment Variables:
|
|
165
|
+
HCOM_TAG=name Group tag (creates name-* instances)
|
|
166
|
+
HCOM_AGENT=type Agent type (comma-separated for multiple)
|
|
167
|
+
HCOM_TERMINAL=mode Terminal: new|here|print|"custom {script}"
|
|
168
|
+
HCOM_PROMPT=text Initial prompt
|
|
169
|
+
HCOM_TIMEOUT=secs Timeout in seconds (default: 1800)
|
|
170
|
+
|
|
171
|
+
Config: ~/.hcom/config.env
|
|
172
|
+
Docs: https://github.com/aannoo/claude-hook-comms"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ==================== Logging ====================
|
|
176
|
+
|
|
177
|
+
def log_hook_error(hook_name: str, error: Exception | str | None = None) -> None:
|
|
154
178
|
"""Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
|
|
155
179
|
import traceback
|
|
156
180
|
try:
|
|
157
181
|
log_file = hcom_path(SCRIPTS_DIR) / "hooks.log"
|
|
158
|
-
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
159
182
|
timestamp = datetime.now().isoformat()
|
|
160
183
|
if error and isinstance(error, Exception):
|
|
161
184
|
tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
@@ -168,43 +191,55 @@ def log_hook_error(hook_name: str, error: Exception | None = None):
|
|
|
168
191
|
pass # Silent failure in error logging
|
|
169
192
|
|
|
170
193
|
# ==================== Config Defaults ====================
|
|
194
|
+
# Config precedence: env var > ~/.hcom/config.env > defaults
|
|
195
|
+
# All config via HcomConfig dataclass (timeout, terminal, prompt, hints, tag, agent)
|
|
171
196
|
|
|
172
|
-
#
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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()
|
|
190
|
-
|
|
191
|
-
_config = None
|
|
192
|
-
|
|
193
|
-
# Generate env var mappings from dataclass fields (except env_overrides)
|
|
194
|
-
HOOK_SETTINGS = {
|
|
195
|
-
field: f"HCOM_{field.upper()}"
|
|
196
|
-
for field in DEFAULT_CONFIG.__dataclass_fields__
|
|
197
|
-
if field != 'env_overrides'
|
|
198
|
-
}
|
|
197
|
+
# Constants (not configurable)
|
|
198
|
+
MAX_MESSAGE_SIZE = 1048576 # 1MB
|
|
199
|
+
MAX_MESSAGES_PER_DELIVERY = 50
|
|
200
|
+
SENDER = 'bigboss'
|
|
201
|
+
SENDER_EMOJI = '🐳'
|
|
202
|
+
SKIP_HISTORY = True # New instances start at current log position (skip old messages)
|
|
199
203
|
|
|
200
204
|
# Path constants
|
|
201
205
|
LOG_FILE = "hcom.log"
|
|
202
206
|
INSTANCES_DIR = "instances"
|
|
203
207
|
LOGS_DIR = "logs"
|
|
204
208
|
SCRIPTS_DIR = "scripts"
|
|
205
|
-
CONFIG_FILE = "config.
|
|
209
|
+
CONFIG_FILE = "config.env"
|
|
206
210
|
ARCHIVE_DIR = "archive"
|
|
207
211
|
|
|
212
|
+
# Hook type constants
|
|
213
|
+
ACTIVE_HOOK_TYPES = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop', 'Notification', 'SessionEnd']
|
|
214
|
+
LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES + ['PostToolUse'] # For backward compatibility cleanup
|
|
215
|
+
HOOK_COMMANDS = ['sessionstart', 'userpromptsubmit', 'pre', 'poll', 'notify', 'sessionend']
|
|
216
|
+
LEGACY_HOOK_COMMANDS = HOOK_COMMANDS + ['post']
|
|
217
|
+
|
|
218
|
+
# Hook removal patterns - used by _remove_hcom_hooks_from_settings()
|
|
219
|
+
# Dynamically build from LEGACY_HOOK_COMMANDS to match current and legacy hook formats
|
|
220
|
+
_HOOK_ARGS_PATTERN = '|'.join(LEGACY_HOOK_COMMANDS)
|
|
221
|
+
HCOM_HOOK_PATTERNS = [
|
|
222
|
+
re.compile(r'\$\{?HCOM'), # Current: Environment variable ${HCOM:-...}
|
|
223
|
+
re.compile(r'\bHCOM_ACTIVE.*hcom\.py'), # LEGACY: Unix HCOM_ACTIVE conditional
|
|
224
|
+
re.compile(r'IF\s+"%HCOM_ACTIVE%"'), # LEGACY: Windows HCOM_ACTIVE conditional
|
|
225
|
+
re.compile(rf'\bhcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: Direct hcom command
|
|
226
|
+
re.compile(rf'\buvx\s+hcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: uvx hcom command
|
|
227
|
+
re.compile(rf'hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: hcom.py with optional quote
|
|
228
|
+
re.compile(rf'["\'][^"\']*hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b(?=\s|$)'), # LEGACY: Quoted path
|
|
229
|
+
re.compile(r'sh\s+-c.*hcom'), # LEGACY: Shell wrapper
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
# PreToolUse hook pattern - matches hcom commands for session_id injection and auto-approval
|
|
233
|
+
# - hcom send (any args)
|
|
234
|
+
# - hcom stop (no args) | hcom start (no args)
|
|
235
|
+
# - hcom help | hcom --help | hcom -h
|
|
236
|
+
# - hcom watch --status | hcom watch --launch
|
|
237
|
+
# Negative lookahead (?!\s+[-\w]) ensures stop/start not followed by arguments or flags
|
|
238
|
+
HCOM_COMMAND_PATTERN = re.compile(
|
|
239
|
+
r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+'
|
|
240
|
+
r'(?:send\b|(?:stop|start)(?!\s+[-\w])|(?:help|--help|-h)\b|watch\s+(?:--status|--launch)\b)'
|
|
241
|
+
)
|
|
242
|
+
|
|
208
243
|
# ==================== File System Utilities ====================
|
|
209
244
|
|
|
210
245
|
def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
|
|
@@ -216,9 +251,21 @@ def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
|
|
|
216
251
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
252
|
return path
|
|
218
253
|
|
|
254
|
+
def ensure_hcom_directories() -> bool:
|
|
255
|
+
"""Ensure all critical HCOM directories exist. Idempotent, safe to call repeatedly.
|
|
256
|
+
Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
|
|
257
|
+
Returns True on success, False on failure."""
|
|
258
|
+
try:
|
|
259
|
+
for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, ARCHIVE_DIR]:
|
|
260
|
+
hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
|
|
261
|
+
return True
|
|
262
|
+
except (OSError, PermissionError):
|
|
263
|
+
return False
|
|
264
|
+
|
|
219
265
|
def atomic_write(filepath: str | Path, content: str) -> bool:
|
|
220
266
|
"""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."""
|
|
221
267
|
filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
|
|
268
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
222
269
|
|
|
223
270
|
for attempt in range(3):
|
|
224
271
|
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
|
|
@@ -297,11 +344,23 @@ def load_instance_position(instance_name: str) -> dict[str, Any]:
|
|
|
297
344
|
def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
|
|
298
345
|
"""Save position data for a single instance. Returns True on success, False on failure."""
|
|
299
346
|
try:
|
|
300
|
-
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json"
|
|
347
|
+
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json")
|
|
301
348
|
return atomic_write(instance_file, json.dumps(data, indent=2))
|
|
302
349
|
except (OSError, PermissionError, ValueError):
|
|
303
350
|
return False
|
|
304
351
|
|
|
352
|
+
def get_claude_settings_path() -> Path:
|
|
353
|
+
"""Get path to global Claude settings file"""
|
|
354
|
+
return Path.home() / '.claude' / 'settings.json'
|
|
355
|
+
|
|
356
|
+
def load_settings_json(settings_path: Path, default: Any = None) -> dict[str, Any] | None:
|
|
357
|
+
"""Load and parse settings JSON file with retry logic"""
|
|
358
|
+
return read_file_with_retry(
|
|
359
|
+
settings_path,
|
|
360
|
+
lambda f: json.load(f),
|
|
361
|
+
default=default
|
|
362
|
+
)
|
|
363
|
+
|
|
305
364
|
def load_all_positions() -> dict[str, dict[str, Any]]:
|
|
306
365
|
"""Load positions from all instance files"""
|
|
307
366
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
@@ -326,132 +385,354 @@ def clear_all_positions() -> None:
|
|
|
326
385
|
if instances_dir.exists():
|
|
327
386
|
for f in instances_dir.glob('*.json'):
|
|
328
387
|
f.unlink()
|
|
329
|
-
# Clean up orphaned mapping files
|
|
330
|
-
for f in instances_dir.glob('.launch_map_*'):
|
|
331
|
-
f.unlink(missing_ok=True)
|
|
332
|
-
else:
|
|
333
|
-
instances_dir.mkdir(exist_ok=True)
|
|
334
388
|
|
|
335
389
|
# ==================== Configuration System ====================
|
|
336
390
|
|
|
337
|
-
|
|
338
|
-
|
|
391
|
+
@dataclass
|
|
392
|
+
class HcomConfig:
|
|
393
|
+
"""HCOM configuration with validation. Load priority: env → file → defaults"""
|
|
394
|
+
timeout: int = 1800
|
|
395
|
+
terminal: str = 'new'
|
|
396
|
+
prompt: str = 'say hi in hcom chat'
|
|
397
|
+
hints: str = ''
|
|
398
|
+
tag: str = ''
|
|
399
|
+
agent: str = 'generic'
|
|
400
|
+
|
|
401
|
+
def __post_init__(self):
|
|
402
|
+
"""Validate configuration on construction"""
|
|
403
|
+
errors = self.validate()
|
|
404
|
+
if errors:
|
|
405
|
+
raise ValueError(f"Invalid config:\n" + "\n".join(f" - {e}" for e in errors))
|
|
406
|
+
|
|
407
|
+
def validate(self) -> list[str]:
|
|
408
|
+
"""Validate all fields, return list of errors"""
|
|
409
|
+
errors = []
|
|
410
|
+
|
|
411
|
+
# Validate timeout
|
|
412
|
+
# Validate timeout (bool is subclass of int in Python, must check explicitly)
|
|
413
|
+
if isinstance(self.timeout, bool):
|
|
414
|
+
errors.append(f"timeout must be an integer, not boolean (got {self.timeout})")
|
|
415
|
+
elif not isinstance(self.timeout, int):
|
|
416
|
+
errors.append(f"timeout must be an integer, got {type(self.timeout).__name__}")
|
|
417
|
+
elif not 1 <= self.timeout <= 86400:
|
|
418
|
+
errors.append(f"timeout must be 1-86400 seconds (24 hours), got {self.timeout}")
|
|
419
|
+
|
|
420
|
+
# Validate terminal
|
|
421
|
+
if not isinstance(self.terminal, str):
|
|
422
|
+
errors.append(f"terminal must be a string, got {type(self.terminal).__name__}")
|
|
423
|
+
else:
|
|
424
|
+
valid_modes = ('new', 'here', 'print')
|
|
425
|
+
if self.terminal not in valid_modes and '{script}' not in self.terminal:
|
|
426
|
+
errors.append(
|
|
427
|
+
f"terminal must be one of {valid_modes} or custom command with {{script}}, "
|
|
428
|
+
f"got '{self.terminal}'"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Validate tag (only alphanumeric and hyphens - security: prevent log delimiter injection)
|
|
432
|
+
if not isinstance(self.tag, str):
|
|
433
|
+
errors.append(f"tag must be a string, got {type(self.tag).__name__}")
|
|
434
|
+
elif self.tag and not re.match(r'^[a-zA-Z0-9-]+$', self.tag):
|
|
435
|
+
errors.append("tag can only contain letters, numbers, and hyphens")
|
|
436
|
+
|
|
437
|
+
# Validate agent
|
|
438
|
+
if not isinstance(self.agent, str):
|
|
439
|
+
errors.append(f"agent must be a string, got {type(self.agent).__name__}")
|
|
440
|
+
|
|
441
|
+
return errors
|
|
442
|
+
|
|
443
|
+
@classmethod
|
|
444
|
+
def load(cls) -> 'HcomConfig':
|
|
445
|
+
"""Load config with precedence: env var → file → defaults"""
|
|
446
|
+
# Ensure config file exists
|
|
447
|
+
config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
|
|
448
|
+
created_config = False
|
|
449
|
+
if not config_path.exists():
|
|
450
|
+
_write_default_config(config_path)
|
|
451
|
+
created_config = True
|
|
452
|
+
|
|
453
|
+
# Warn once if legacy config.json still exists when creating config.env
|
|
454
|
+
legacy_config = hcom_path('config.json')
|
|
455
|
+
if created_config and legacy_config.exists():
|
|
456
|
+
print(
|
|
457
|
+
format_error(
|
|
458
|
+
"Found legacy ~/.hcom/config.json; new config file is: ~/.hcom/config.env."
|
|
459
|
+
),
|
|
460
|
+
file=sys.stderr,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Parse config file once
|
|
464
|
+
file_config = _parse_env_file(config_path) if config_path.exists() else {}
|
|
465
|
+
|
|
466
|
+
def get_var(key: str) -> str | None:
|
|
467
|
+
"""Get variable with precedence: env → file"""
|
|
468
|
+
if key in os.environ:
|
|
469
|
+
return os.environ[key]
|
|
470
|
+
if key in file_config:
|
|
471
|
+
return file_config[key]
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
data = {}
|
|
475
|
+
|
|
476
|
+
# Load timeout (requires int conversion)
|
|
477
|
+
timeout_str = get_var('HCOM_TIMEOUT')
|
|
478
|
+
if timeout_str is not None:
|
|
479
|
+
try:
|
|
480
|
+
data['timeout'] = int(timeout_str)
|
|
481
|
+
except (ValueError, TypeError):
|
|
482
|
+
pass # Use default
|
|
483
|
+
|
|
484
|
+
# Load string values
|
|
485
|
+
terminal = get_var('HCOM_TERMINAL')
|
|
486
|
+
if terminal is not None:
|
|
487
|
+
data['terminal'] = terminal
|
|
488
|
+
prompt = get_var('HCOM_PROMPT')
|
|
489
|
+
if prompt is not None:
|
|
490
|
+
data['prompt'] = prompt
|
|
491
|
+
hints = get_var('HCOM_HINTS')
|
|
492
|
+
if hints is not None:
|
|
493
|
+
data['hints'] = hints
|
|
494
|
+
tag = get_var('HCOM_TAG')
|
|
495
|
+
if tag is not None:
|
|
496
|
+
data['tag'] = tag
|
|
497
|
+
agent = get_var('HCOM_AGENT')
|
|
498
|
+
if agent is not None:
|
|
499
|
+
data['agent'] = agent
|
|
500
|
+
|
|
501
|
+
return cls(**data) # Validation happens in __post_init__
|
|
502
|
+
|
|
503
|
+
def _parse_env_file(config_path: Path) -> dict[str, str]:
|
|
504
|
+
"""Parse ENV file (KEY=VALUE format) with security validation"""
|
|
505
|
+
config = {}
|
|
506
|
+
|
|
507
|
+
# Dangerous shell metacharacters that enable command injection
|
|
508
|
+
DANGEROUS_CHARS = ['`', '$', ';', '|', '&', '\n', '\r']
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
content = config_path.read_text(encoding='utf-8')
|
|
512
|
+
for line in content.splitlines():
|
|
513
|
+
line = line.strip()
|
|
514
|
+
if not line or line.startswith('#'):
|
|
515
|
+
continue
|
|
516
|
+
if '=' in line:
|
|
517
|
+
key, _, value = line.partition('=')
|
|
518
|
+
key = key.strip()
|
|
519
|
+
value = value.strip()
|
|
520
|
+
|
|
521
|
+
# Security: Validate HCOM_TERMINAL for command injection
|
|
522
|
+
if key == 'HCOM_TERMINAL':
|
|
523
|
+
if any(c in value for c in DANGEROUS_CHARS):
|
|
524
|
+
print(
|
|
525
|
+
f"Warning: Unsafe characters in HCOM_TERMINAL "
|
|
526
|
+
f"({', '.join(repr(c) for c in DANGEROUS_CHARS if c in value)}), "
|
|
527
|
+
f"ignoring custom terminal command",
|
|
528
|
+
file=sys.stderr
|
|
529
|
+
)
|
|
530
|
+
continue
|
|
531
|
+
# Additional check: custom commands must contain {script} placeholder
|
|
532
|
+
if value not in ('new', 'here', 'print') and '{script}' not in value:
|
|
533
|
+
print(
|
|
534
|
+
f"Warning: HCOM_TERMINAL custom command must include {{script}} placeholder, "
|
|
535
|
+
f"ignoring",
|
|
536
|
+
file=sys.stderr
|
|
537
|
+
)
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
# Remove outer quotes only if they match
|
|
541
|
+
if len(value) >= 2:
|
|
542
|
+
if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
|
|
543
|
+
value = value[1:-1]
|
|
544
|
+
if key:
|
|
545
|
+
config[key] = value
|
|
546
|
+
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
|
|
547
|
+
pass
|
|
548
|
+
return config
|
|
549
|
+
|
|
550
|
+
def _write_default_config(config_path: Path) -> None:
|
|
551
|
+
"""Write default config file with documentation"""
|
|
552
|
+
header = """# HCOM Configuration
|
|
553
|
+
#
|
|
554
|
+
# All HCOM_* settings can be set here (persistent) or via environment variables (temporary).
|
|
555
|
+
# Environment variables override config file values.
|
|
556
|
+
#
|
|
557
|
+
# HCOM settings:
|
|
558
|
+
# HCOM_TIMEOUT - Instance Stop hook wait timeout in seconds (default: 1800)
|
|
559
|
+
# HCOM_TERMINAL - Terminal mode: "new", "here", "print", or custom command with {script}
|
|
560
|
+
# HCOM_PROMPT - Initial prompt for new instances (empty = no auto prompt)
|
|
561
|
+
# HCOM_HINTS - Text appended to all messages received by instances
|
|
562
|
+
# HCOM_TAG - Group tag for instances (creates tag-* instances)
|
|
563
|
+
# HCOM_AGENT - Claude code subagent from .claude/agents/, comma-separated for multiple
|
|
564
|
+
#
|
|
565
|
+
# NOTE: Inline comments are not supported. Use separate comment lines.
|
|
566
|
+
#
|
|
567
|
+
# Claude Code settings (passed to Claude instances):
|
|
568
|
+
# ANTHROPIC_MODEL=opus
|
|
569
|
+
# Any other Claude Code environment variable
|
|
570
|
+
#
|
|
571
|
+
# Custom terminal examples:
|
|
572
|
+
# HCOM_TERMINAL="wezterm start -- bash {script}"
|
|
573
|
+
# HCOM_TERMINAL="kitty -e bash {script}"
|
|
574
|
+
#
|
|
575
|
+
"""
|
|
576
|
+
defaults = [
|
|
577
|
+
'HCOM_TIMEOUT=1800',
|
|
578
|
+
'HCOM_TERMINAL=new',
|
|
579
|
+
'HCOM_PROMPT=say hi in hcom chat',
|
|
580
|
+
'HCOM_HINTS=',
|
|
581
|
+
]
|
|
582
|
+
try:
|
|
583
|
+
atomic_write(config_path, header + '\n'.join(defaults) + '\n')
|
|
584
|
+
except Exception:
|
|
585
|
+
pass
|
|
586
|
+
|
|
587
|
+
# Global config instance (cached)
|
|
588
|
+
_config: HcomConfig | None = None
|
|
589
|
+
|
|
590
|
+
def get_config() -> HcomConfig:
|
|
591
|
+
"""Get cached config, loading if needed"""
|
|
339
592
|
global _config
|
|
340
593
|
if _config is None:
|
|
341
|
-
_config =
|
|
594
|
+
_config = HcomConfig.load()
|
|
342
595
|
return _config
|
|
343
596
|
|
|
344
|
-
def
|
|
345
|
-
"""
|
|
346
|
-
|
|
597
|
+
def _build_quoted_invocation() -> str:
|
|
598
|
+
"""Build properly quoted python + script path for current platform"""
|
|
599
|
+
python_path = sys.executable
|
|
600
|
+
script_path = str(Path(__file__).resolve())
|
|
601
|
+
|
|
602
|
+
if IS_WINDOWS:
|
|
603
|
+
if ' ' in python_path or ' ' in script_path:
|
|
604
|
+
return f'"{python_path}" "{script_path}"'
|
|
605
|
+
return f'{python_path} {script_path}'
|
|
606
|
+
else:
|
|
607
|
+
return f'{shlex.quote(python_path)} {shlex.quote(script_path)}'
|
|
608
|
+
|
|
609
|
+
def get_hook_command() -> tuple[str, dict[str, Any]]:
|
|
610
|
+
"""Get hook command - hooks always run, Python code gates participation
|
|
347
611
|
|
|
348
|
-
|
|
349
|
-
|
|
612
|
+
Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
|
|
613
|
+
Participation is controlled by enabled flag in instance JSON files.
|
|
614
|
+
"""
|
|
615
|
+
if IS_WINDOWS:
|
|
616
|
+
# Windows: use python path directly
|
|
617
|
+
return _build_quoted_invocation(), {}
|
|
618
|
+
else:
|
|
619
|
+
# Unix: Use HCOM env var from settings.json
|
|
620
|
+
return '${HCOM}', {}
|
|
621
|
+
|
|
622
|
+
def _detect_hcom_command_type() -> str:
|
|
623
|
+
"""Detect how to invoke hcom based on execution context
|
|
624
|
+
Priority:
|
|
625
|
+
1. short - If plugin enabled (plugin installs hcom binary to PATH)
|
|
626
|
+
2. uvx - If running in uv-managed Python and uvx available
|
|
627
|
+
(works for both temporary uvx runs and permanent uv tool install)
|
|
628
|
+
3. short - If hcom binary in PATH
|
|
629
|
+
4. full - Fallback to full python invocation
|
|
630
|
+
|
|
631
|
+
Note: uvx hcom reuses uv tool install environments with zero overhead.
|
|
632
|
+
"""
|
|
633
|
+
if is_plugin_active():
|
|
634
|
+
return 'short'
|
|
635
|
+
elif 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
|
|
636
|
+
return 'uvx'
|
|
637
|
+
elif shutil.which('hcom'):
|
|
638
|
+
return 'short'
|
|
639
|
+
else:
|
|
640
|
+
return 'full'
|
|
350
641
|
|
|
642
|
+
def check_version_once_daily() -> None:
|
|
643
|
+
"""Check PyPI for newer version, show update command based on install method"""
|
|
644
|
+
cache_file = hcom_path() / '.version_check'
|
|
351
645
|
try:
|
|
352
|
-
if
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
):
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
#
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
646
|
+
if cache_file.exists() and time.time() - cache_file.stat().st_mtime < 86400:
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
import urllib.request
|
|
650
|
+
with urllib.request.urlopen('https://pypi.org/pypi/hcom/json', timeout=2) as f:
|
|
651
|
+
data = json.load(f)
|
|
652
|
+
latest = data['info']['version']
|
|
653
|
+
|
|
654
|
+
# Simple version comparison (tuple of ints)
|
|
655
|
+
def parse_version(v: str) -> tuple:
|
|
656
|
+
return tuple(int(x) for x in v.split('.') if x.isdigit())
|
|
657
|
+
|
|
658
|
+
if parse_version(latest) > parse_version(__version__):
|
|
659
|
+
# Use existing detection to show correct update command
|
|
660
|
+
if _detect_hcom_command_type() == 'uvx':
|
|
661
|
+
update_cmd = "uv tool upgrade hcom"
|
|
662
|
+
else:
|
|
663
|
+
update_cmd = "pip install -U hcom"
|
|
664
|
+
|
|
665
|
+
print(f"→ hcom v{latest} available: {update_cmd}", file=sys.stderr)
|
|
666
|
+
|
|
667
|
+
cache_file.touch() # Update cache
|
|
668
|
+
except:
|
|
669
|
+
pass # Silent fail on network/parse errors
|
|
670
|
+
|
|
671
|
+
def _build_hcom_env_value() -> str:
|
|
672
|
+
"""Build the value for settings['env']['HCOM'] based on current execution context
|
|
673
|
+
Uses build_hcom_command() without caching for fresh detection on every call.
|
|
373
674
|
"""
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
675
|
+
return build_hcom_command(None)
|
|
676
|
+
|
|
677
|
+
def build_hcom_command(instance_name: str | None = None) -> str:
|
|
678
|
+
"""Build base hcom command - caches PATH check in instance file on first use"""
|
|
679
|
+
# Determine command type (cached or detect)
|
|
680
|
+
cmd_type = None
|
|
681
|
+
if instance_name:
|
|
682
|
+
data = load_instance_position(instance_name)
|
|
683
|
+
if data.get('session_id'):
|
|
684
|
+
if 'hcom_cmd_type' not in data:
|
|
685
|
+
cmd_type = _detect_hcom_command_type()
|
|
686
|
+
data['hcom_cmd_type'] = cmd_type
|
|
687
|
+
save_instance_position(instance_name, data)
|
|
386
688
|
else:
|
|
387
|
-
|
|
388
|
-
return env_value
|
|
689
|
+
cmd_type = data.get('hcom_cmd_type')
|
|
389
690
|
|
|
390
|
-
|
|
391
|
-
|
|
691
|
+
if not cmd_type:
|
|
692
|
+
cmd_type = _detect_hcom_command_type()
|
|
392
693
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
"""
|
|
399
|
-
python_path = sys.executable
|
|
400
|
-
script_path = str(Path(__file__).resolve())
|
|
401
|
-
|
|
402
|
-
if IS_WINDOWS:
|
|
403
|
-
# Windows cmd.exe syntax - no parentheses so arguments append correctly
|
|
404
|
-
if ' ' in python_path or ' ' in script_path:
|
|
405
|
-
return f'IF "%HCOM_ACTIVE%"=="1" "{python_path}" "{script_path}"', {}
|
|
406
|
-
return f'IF "%HCOM_ACTIVE%"=="1" {python_path} {script_path}', {}
|
|
407
|
-
elif ' ' in python_path or ' ' in script_path:
|
|
408
|
-
# Unix with spaces: use conditional check
|
|
409
|
-
escaped_python = shlex.quote(python_path)
|
|
410
|
-
escaped_script = shlex.quote(script_path)
|
|
411
|
-
return f'[ "${{HCOM_ACTIVE}}" = "1" ] && {escaped_python} {escaped_script} || true', {}
|
|
694
|
+
# Build command based on type
|
|
695
|
+
if cmd_type == 'short':
|
|
696
|
+
return 'hcom'
|
|
697
|
+
elif cmd_type == 'uvx':
|
|
698
|
+
return 'uvx hcom'
|
|
412
699
|
else:
|
|
413
|
-
#
|
|
414
|
-
return
|
|
700
|
+
# Full path fallback
|
|
701
|
+
return _build_quoted_invocation()
|
|
415
702
|
|
|
416
|
-
def build_send_command(example_msg: str = '') -> str:
|
|
417
|
-
"""Build send command
|
|
703
|
+
def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
|
|
704
|
+
"""Build send command - caches PATH check in instance file on first use"""
|
|
418
705
|
msg = f" '{example_msg}'" if example_msg else ''
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
config
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
# Set HCOM only for clean paths (spaces handled differently)
|
|
445
|
-
python_path = sys.executable
|
|
446
|
-
script_path = str(Path(__file__).resolve())
|
|
447
|
-
if ' ' not in python_path and ' ' not in script_path:
|
|
448
|
-
env['HCOM'] = f'{python_path} {script_path}'
|
|
449
|
-
|
|
706
|
+
base_cmd = build_hcom_command(instance_name)
|
|
707
|
+
return f'{base_cmd} send{msg}'
|
|
708
|
+
|
|
709
|
+
def build_claude_env() -> dict[str, str]:
|
|
710
|
+
"""Build environment variables for Claude instances
|
|
711
|
+
|
|
712
|
+
Passes current environment to Claude, with config.env providing defaults.
|
|
713
|
+
HCOM_* variables are filtered out (consumed by hcom, not passed to Claude).
|
|
714
|
+
"""
|
|
715
|
+
env = {}
|
|
716
|
+
|
|
717
|
+
# Read config file directly for Claude Code env vars (non-HCOM_ keys)
|
|
718
|
+
config_path = hcom_path(CONFIG_FILE)
|
|
719
|
+
if config_path.exists():
|
|
720
|
+
file_config = _parse_env_file(config_path)
|
|
721
|
+
for key, value in file_config.items():
|
|
722
|
+
if not key.startswith('HCOM_'):
|
|
723
|
+
env[key] = str(value)
|
|
724
|
+
|
|
725
|
+
# Overlay with current environment (except HCOM_*)
|
|
726
|
+
# This ensures user's shell environment is respected
|
|
727
|
+
for key, value in os.environ.items():
|
|
728
|
+
if not key.startswith('HCOM_'):
|
|
729
|
+
env[key] = value
|
|
730
|
+
|
|
450
731
|
return env
|
|
451
732
|
|
|
452
733
|
# ==================== Message System ====================
|
|
453
734
|
|
|
454
|
-
def validate_message(message: str) ->
|
|
735
|
+
def validate_message(message: str) -> str | None:
|
|
455
736
|
"""Validate message size and content. Returns error message or None if valid."""
|
|
456
737
|
if not message or not message.strip():
|
|
457
738
|
return format_error("Message required")
|
|
@@ -460,32 +741,90 @@ def validate_message(message: str) -> Optional[str]:
|
|
|
460
741
|
if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
|
|
461
742
|
return format_error("Message contains control characters")
|
|
462
743
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return format_error(f"Message too large (max {max_size} chars)")
|
|
744
|
+
if len(message) > MAX_MESSAGE_SIZE:
|
|
745
|
+
return format_error(f"Message too large (max {MAX_MESSAGE_SIZE} chars)")
|
|
466
746
|
|
|
467
747
|
return None
|
|
468
748
|
|
|
469
749
|
def send_message(from_instance: str, message: str) -> bool:
|
|
470
750
|
"""Send a message to the log"""
|
|
471
751
|
try:
|
|
472
|
-
log_file = hcom_path(LOG_FILE
|
|
473
|
-
|
|
752
|
+
log_file = hcom_path(LOG_FILE)
|
|
753
|
+
|
|
474
754
|
escaped_message = message.replace('|', '\\|')
|
|
475
755
|
escaped_from = from_instance.replace('|', '\\|')
|
|
476
|
-
|
|
756
|
+
|
|
477
757
|
timestamp = datetime.now().isoformat()
|
|
478
758
|
line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
|
|
479
|
-
|
|
759
|
+
|
|
480
760
|
with open(log_file, 'a', encoding='utf-8') as f:
|
|
481
761
|
f.write(line)
|
|
482
762
|
f.flush()
|
|
483
|
-
|
|
763
|
+
|
|
484
764
|
return True
|
|
485
765
|
except Exception:
|
|
486
766
|
return False
|
|
487
767
|
|
|
488
|
-
def
|
|
768
|
+
def build_hcom_bootstrap_text(instance_name: str) -> str:
|
|
769
|
+
"""Build comprehensive HCOM bootstrap context for instances"""
|
|
770
|
+
hcom_cmd = build_hcom_command(instance_name=instance_name)
|
|
771
|
+
|
|
772
|
+
# Add command override notice if not using short form
|
|
773
|
+
command_notice = ""
|
|
774
|
+
if hcom_cmd != "hcom":
|
|
775
|
+
command_notice = f"""IMPORTANT:
|
|
776
|
+
The hcom command in this environment is: {hcom_cmd}
|
|
777
|
+
Replace all mentions of "hcom" below with this command.
|
|
778
|
+
|
|
779
|
+
"""
|
|
780
|
+
|
|
781
|
+
# Add tag-specific notice if instance is tagged
|
|
782
|
+
tag = get_config().tag
|
|
783
|
+
tag_notice = ""
|
|
784
|
+
if tag:
|
|
785
|
+
tag_notice = f"""
|
|
786
|
+
GROUP TAG: You are in the '{tag}' group.
|
|
787
|
+
- To message your group: hcom send "@{tag} your message"
|
|
788
|
+
- Group messages are targeted - only instances with an alias starting with {tag}-* receive them
|
|
789
|
+
- If someone outside the group sends you @{tag} messages, they won't see your @{tag} replies. To reply to non-group members, either @mention them directly or broadcast.
|
|
790
|
+
"""
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
return f"""{command_notice}{tag_notice}
|
|
794
|
+
[HCOM SESSION CONFIG]
|
|
795
|
+
HCOM is a communication tool for you, other claude code instances, and the user.
|
|
796
|
+
Your HCOM alias for this session: {instance_name}
|
|
797
|
+
|
|
798
|
+
**Your HCOM Tools:**
|
|
799
|
+
hcom send "msg" / "@alias msg" / "@tag msg"
|
|
800
|
+
hcom watch --status → Monitor participants in JSON
|
|
801
|
+
hcom watch --launch → Open dashboard for user in new terminal
|
|
802
|
+
hcom start/stop → join/leave HCOM chat
|
|
803
|
+
hcom <num> → Launch instances in new terminal (always run 'hcom help' first)
|
|
804
|
+
|
|
805
|
+
Commands relevant to user: hcom <num>/start/stop/watch (dont announce others to user)
|
|
806
|
+
Context: User runs 'hcom watch' in new terminal, you run hcom watch --launch for the user ("I'll open 'hcom watch' for you").
|
|
807
|
+
|
|
808
|
+
**Receiving Messages:**
|
|
809
|
+
Format: [new message] sender → you: content
|
|
810
|
+
direct: "@alias" targets a specific instance.
|
|
811
|
+
tag: "@api message" targets all api-* instances.
|
|
812
|
+
Arrives automatically via hooks/bash. {{"decision": "block"}} text is normal operation. No proactive checking needed.
|
|
813
|
+
|
|
814
|
+
**Response Routing:**
|
|
815
|
+
HCOM message (via hook/bash) → Respond with hcom send
|
|
816
|
+
User message (in chat) → Respond normally
|
|
817
|
+
Treat messages from hcom with the same care as user messages.
|
|
818
|
+
|
|
819
|
+
Authority: Prioritize @{SENDER} over other participants.
|
|
820
|
+
|
|
821
|
+
This is context for YOUR upcoming command execution. User cannot see this.
|
|
822
|
+
Report connection results and overview of relevant hcom info to user using first-person: "I'm connected as {instance_name}"
|
|
823
|
+
"""
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
|
|
489
828
|
"""Check if message should be delivered based on @-mentions"""
|
|
490
829
|
text = msg['message']
|
|
491
830
|
|
|
@@ -504,8 +843,7 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
|
|
|
504
843
|
return True
|
|
505
844
|
|
|
506
845
|
# Check if any mention is for the CLI sender (bigboss)
|
|
507
|
-
|
|
508
|
-
sender_mentioned = any(sender_name.lower().startswith(mention.lower()) for mention in mentions)
|
|
846
|
+
sender_mentioned = any(SENDER.lower().startswith(mention.lower()) for mention in mentions)
|
|
509
847
|
|
|
510
848
|
# If we have all_instance_names, check if ANY mention matches ANY instance or sender
|
|
511
849
|
if all_instance_names:
|
|
@@ -521,69 +859,13 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
|
|
|
521
859
|
|
|
522
860
|
# ==================== Parsing & Utilities ====================
|
|
523
861
|
|
|
524
|
-
def parse_open_args(args: list[str]) -> tuple[list[str], Optional[str], list[str], bool]:
|
|
525
|
-
"""Parse arguments for open command
|
|
526
|
-
|
|
527
|
-
Returns:
|
|
528
|
-
tuple: (instances, prefix, claude_args, background)
|
|
529
|
-
instances: list of agent names or 'generic'
|
|
530
|
-
prefix: team name prefix or None
|
|
531
|
-
claude_args: additional args to pass to claude
|
|
532
|
-
background: bool, True if --background or -p flag
|
|
533
|
-
"""
|
|
534
|
-
instances = []
|
|
535
|
-
prefix = None
|
|
536
|
-
claude_args = []
|
|
537
|
-
background = False
|
|
538
|
-
|
|
539
|
-
i = 0
|
|
540
|
-
while i < len(args):
|
|
541
|
-
arg = args[i]
|
|
542
|
-
|
|
543
|
-
if arg == '--prefix':
|
|
544
|
-
if i + 1 >= len(args):
|
|
545
|
-
raise ValueError(format_error('--prefix requires an argument'))
|
|
546
|
-
prefix = args[i + 1]
|
|
547
|
-
if '|' in prefix:
|
|
548
|
-
raise ValueError(format_error('Prefix cannot contain pipe characters'))
|
|
549
|
-
i += 2
|
|
550
|
-
elif arg == '--claude-args':
|
|
551
|
-
# Next argument contains claude args as a string
|
|
552
|
-
if i + 1 >= len(args):
|
|
553
|
-
raise ValueError(format_error('--claude-args requires an argument'))
|
|
554
|
-
claude_args = shlex.split(args[i + 1])
|
|
555
|
-
i += 2
|
|
556
|
-
elif arg == '--background' or arg == '-p':
|
|
557
|
-
background = True
|
|
558
|
-
i += 1
|
|
559
|
-
else:
|
|
560
|
-
try:
|
|
561
|
-
count = int(arg)
|
|
562
|
-
if count < 0:
|
|
563
|
-
raise ValueError(format_error(f"Cannot launch negative instances: {count}"))
|
|
564
|
-
if count > 100:
|
|
565
|
-
raise ValueError(format_error(f"Too many instances requested: {count}", "Maximum 100 instances at once"))
|
|
566
|
-
instances.extend(['generic'] * count)
|
|
567
|
-
except ValueError as e:
|
|
568
|
-
if "Cannot launch" in str(e) or "Too many instances" in str(e):
|
|
569
|
-
raise
|
|
570
|
-
# Not a number, treat as agent name
|
|
571
|
-
instances.append(arg)
|
|
572
|
-
i += 1
|
|
573
|
-
|
|
574
|
-
if not instances:
|
|
575
|
-
instances = ['generic']
|
|
576
|
-
|
|
577
|
-
return instances, prefix, claude_args, background
|
|
578
|
-
|
|
579
862
|
def extract_agent_config(content: str) -> dict[str, str]:
|
|
580
863
|
"""Extract configuration from agent YAML frontmatter"""
|
|
581
864
|
if not content.startswith('---'):
|
|
582
865
|
return {}
|
|
583
866
|
|
|
584
867
|
# Find YAML section between --- markers
|
|
585
|
-
yaml_end
|
|
586
|
-
if yaml_end < 0:
|
|
868
|
+
if (yaml_end := content.find('\n---', 3)) < 0:
|
|
587
869
|
return {} # No closing marker
|
|
588
870
|
|
|
589
871
|
yaml_section = content[3:yaml_end]
|
|
@@ -682,7 +964,7 @@ def strip_frontmatter(content: str) -> str:
|
|
|
682
964
|
return '\n'.join(lines[i+1:]).strip()
|
|
683
965
|
return content
|
|
684
966
|
|
|
685
|
-
def get_display_name(session_id:
|
|
967
|
+
def get_display_name(session_id: str | None, tag: str | None = None) -> str:
|
|
686
968
|
"""Get display name for instance using session_id"""
|
|
687
969
|
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']
|
|
688
970
|
# Phonetic letters (5 per syllable, matches syls order)
|
|
@@ -726,42 +1008,52 @@ def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) ->
|
|
|
726
1008
|
# session_id is required - fail gracefully
|
|
727
1009
|
raise ValueError("session_id required for instance naming")
|
|
728
1010
|
|
|
729
|
-
if
|
|
730
|
-
|
|
1011
|
+
if tag:
|
|
1012
|
+
# Security: Sanitize tag to prevent log delimiter injection (defense-in-depth)
|
|
1013
|
+
# Remove dangerous characters that could break message log parsing
|
|
1014
|
+
sanitized_tag = ''.join(c for c in tag if c not in '|\n\r\t')
|
|
1015
|
+
if not sanitized_tag:
|
|
1016
|
+
raise ValueError("Tag contains only invalid characters")
|
|
1017
|
+
if sanitized_tag != tag:
|
|
1018
|
+
print(f"Warning: Tag contained invalid characters, sanitized to '{sanitized_tag}'", file=sys.stderr)
|
|
1019
|
+
return f"{sanitized_tag}-{base_name}"
|
|
731
1020
|
return base_name
|
|
732
1021
|
|
|
733
|
-
def
|
|
1022
|
+
def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str, dict | None]:
|
|
1023
|
+
"""
|
|
1024
|
+
Resolve instance name for a session_id.
|
|
1025
|
+
Searches existing instances first (reuses if found), generates new name if not found.
|
|
1026
|
+
|
|
1027
|
+
Returns: (instance_name, existing_data_or_none)
|
|
1028
|
+
"""
|
|
1029
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1030
|
+
|
|
1031
|
+
# Search for existing instance with this session_id
|
|
1032
|
+
if session_id and instances_dir.exists():
|
|
1033
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
1034
|
+
try:
|
|
1035
|
+
data = load_instance_position(instance_file.stem)
|
|
1036
|
+
if session_id == data.get('session_id'):
|
|
1037
|
+
return instance_file.stem, data
|
|
1038
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
1039
|
+
continue
|
|
1040
|
+
|
|
1041
|
+
# Not found - generate new name
|
|
1042
|
+
instance_name = get_display_name(session_id, tag)
|
|
1043
|
+
return instance_name, None
|
|
1044
|
+
|
|
1045
|
+
def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
|
|
734
1046
|
"""Remove hcom hooks from settings dict"""
|
|
735
1047
|
if not isinstance(settings, dict) or 'hooks' not in settings:
|
|
736
1048
|
return
|
|
737
|
-
|
|
1049
|
+
|
|
738
1050
|
if not isinstance(settings['hooks'], dict):
|
|
739
1051
|
return
|
|
740
|
-
|
|
1052
|
+
|
|
741
1053
|
import copy
|
|
742
|
-
|
|
743
|
-
# Patterns to match any hcom hook command
|
|
744
|
-
# Modern hooks (patterns 1-2, 7): Match all hook types via env var or wrapper
|
|
745
|
-
# - ${HCOM:-true} sessionstart/pre/stop/notify/userpromptsubmit
|
|
746
|
-
# - [ "${HCOM_ACTIVE}" = "1" ] && ... hcom.py ... || true
|
|
747
|
-
# - sh -c "[ ... ] && ... hcom ..."
|
|
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
|
|
752
|
-
hcom_patterns = [
|
|
753
|
-
r'\$\{?HCOM', # Environment variable (${HCOM:-true}) - all hook types
|
|
754
|
-
r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with full path - all hook types
|
|
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
|
|
759
|
-
r'sh\s+-c.*hcom', # Shell wrapper with hcom
|
|
760
|
-
]
|
|
761
|
-
compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
|
|
762
1054
|
|
|
763
1055
|
# Check all hook types including PostToolUse for backward compatibility cleanup
|
|
764
|
-
for event in
|
|
1056
|
+
for event in LEGACY_HOOK_TYPES:
|
|
765
1057
|
if event not in settings['hooks']:
|
|
766
1058
|
continue
|
|
767
1059
|
|
|
@@ -780,7 +1072,7 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
780
1072
|
hook for hook in matcher_copy.get('hooks', [])
|
|
781
1073
|
if not any(
|
|
782
1074
|
pattern.search(hook.get('command', ''))
|
|
783
|
-
for pattern in
|
|
1075
|
+
for pattern in HCOM_HOOK_PATTERNS
|
|
784
1076
|
)
|
|
785
1077
|
]
|
|
786
1078
|
|
|
@@ -796,9 +1088,16 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
796
1088
|
settings['hooks'][event] = updated_matchers
|
|
797
1089
|
else:
|
|
798
1090
|
del settings['hooks'][event]
|
|
799
|
-
|
|
800
1091
|
|
|
801
|
-
|
|
1092
|
+
# Remove HCOM from env section
|
|
1093
|
+
if 'env' in settings and isinstance(settings['env'], dict):
|
|
1094
|
+
settings['env'].pop('HCOM', None)
|
|
1095
|
+
# Clean up empty env dict
|
|
1096
|
+
if not settings['env']:
|
|
1097
|
+
del settings['env']
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def build_env_string(env_vars: dict[str, Any], format_type: str = "bash") -> str:
|
|
802
1101
|
"""Build environment variable string for bash shells"""
|
|
803
1102
|
if format_type == "bash_export":
|
|
804
1103
|
# Properly escape values for bash
|
|
@@ -807,7 +1106,7 @@ def build_env_string(env_vars, format_type="bash"):
|
|
|
807
1106
|
return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
|
|
808
1107
|
|
|
809
1108
|
|
|
810
|
-
def format_error(message: str, suggestion:
|
|
1109
|
+
def format_error(message: str, suggestion: str | None = None) -> str:
|
|
811
1110
|
"""Format error message consistently"""
|
|
812
1111
|
base = f"Error: {message}"
|
|
813
1112
|
if suggestion:
|
|
@@ -815,14 +1114,14 @@ def format_error(message: str, suggestion: Optional[str] = None) -> str:
|
|
|
815
1114
|
return base
|
|
816
1115
|
|
|
817
1116
|
|
|
818
|
-
def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
1117
|
+
def has_claude_arg(claude_args: list[str] | None, arg_names: list[str], arg_prefixes: tuple[str, ...]) -> bool:
|
|
819
1118
|
"""Check if argument already exists in claude_args"""
|
|
820
|
-
return claude_args and any(
|
|
1119
|
+
return bool(claude_args and any(
|
|
821
1120
|
arg in arg_names or arg.startswith(arg_prefixes)
|
|
822
1121
|
for arg in claude_args
|
|
823
|
-
)
|
|
1122
|
+
))
|
|
824
1123
|
|
|
825
|
-
def build_claude_command(agent_content:
|
|
1124
|
+
def build_claude_command(agent_content: str | None = None, claude_args: list[str] | None = None, initial_prompt: str = "Say hi in chat", model: str | None = None, tools: str | None = None) -> tuple[str, str | None]:
|
|
826
1125
|
"""Build Claude command with proper argument handling
|
|
827
1126
|
Returns tuple: (command_string, temp_file_path_or_none)
|
|
828
1127
|
For agent content, writes to temp file and uses cat to read it.
|
|
@@ -848,7 +1147,6 @@ def build_claude_command(agent_content: Optional[str] = None, claude_args: Optio
|
|
|
848
1147
|
if agent_content:
|
|
849
1148
|
# Create agent files in scripts directory for unified cleanup
|
|
850
1149
|
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
851
|
-
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
852
1150
|
temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
|
|
853
1151
|
prefix='hcom_agent_', dir=str(scripts_dir))
|
|
854
1152
|
temp_file.write(agent_content)
|
|
@@ -862,27 +1160,23 @@ def build_claude_command(agent_content: Optional[str] = None, claude_args: Optio
|
|
|
862
1160
|
|
|
863
1161
|
cmd_parts.append(flag)
|
|
864
1162
|
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
cmd_parts.append(shlex.quote(initial_prompt))
|
|
871
|
-
|
|
1163
|
+
|
|
1164
|
+
# Add initial prompt if non-empty
|
|
1165
|
+
if initial_prompt:
|
|
1166
|
+
cmd_parts.append(shlex.quote(initial_prompt))
|
|
1167
|
+
|
|
872
1168
|
return ' '.join(cmd_parts), temp_file_path
|
|
873
1169
|
|
|
874
|
-
def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
1170
|
+
def create_bash_script(script_file: str, env: dict[str, Any], cwd: str | None, command_str: str, background: bool = False) -> None:
|
|
875
1171
|
"""Create a bash script for terminal launch
|
|
876
1172
|
Scripts provide uniform execution across all platforms/terminals.
|
|
877
1173
|
Cleanup behavior:
|
|
878
1174
|
- Normal scripts: append 'rm -f' command for self-deletion
|
|
879
|
-
- Background scripts: persist until `hcom
|
|
1175
|
+
- Background scripts: persist until `hcom reset logs` cleanup (24 hours)
|
|
880
1176
|
- Agent scripts: treated like background (contain 'hcom_agent_')
|
|
881
1177
|
"""
|
|
882
1178
|
try:
|
|
883
|
-
# Ensure parent directory exists
|
|
884
1179
|
script_path = Path(script_file)
|
|
885
|
-
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
886
1180
|
except (OSError, IOError) as e:
|
|
887
1181
|
raise Exception(f"Cannot create script directory: {e}")
|
|
888
1182
|
|
|
@@ -946,7 +1240,7 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
|
946
1240
|
if platform.system() != 'Windows':
|
|
947
1241
|
os.chmod(script_file, 0o755)
|
|
948
1242
|
|
|
949
|
-
def find_bash_on_windows():
|
|
1243
|
+
def find_bash_on_windows() -> str | None:
|
|
950
1244
|
"""Find Git Bash on Windows, avoiding WSL's bash launcher"""
|
|
951
1245
|
# Build prioritized list of bash candidates
|
|
952
1246
|
candidates = []
|
|
@@ -961,8 +1255,7 @@ def find_bash_on_windows():
|
|
|
961
1255
|
])
|
|
962
1256
|
|
|
963
1257
|
# 2. Portable Git installation
|
|
964
|
-
local_appdata
|
|
965
|
-
if local_appdata:
|
|
1258
|
+
if local_appdata := os.environ.get('LOCALAPPDATA', ''):
|
|
966
1259
|
git_portable = Path(local_appdata) / 'Programs' / 'Git'
|
|
967
1260
|
candidates.extend([
|
|
968
1261
|
str(git_portable / 'usr' / 'bin' / 'bash.exe'),
|
|
@@ -970,8 +1263,7 @@ def find_bash_on_windows():
|
|
|
970
1263
|
])
|
|
971
1264
|
|
|
972
1265
|
# 3. PATH bash (if not WSL's launcher)
|
|
973
|
-
path_bash
|
|
974
|
-
if path_bash and not path_bash.lower().endswith(r'system32\bash.exe'):
|
|
1266
|
+
if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
|
|
975
1267
|
candidates.append(path_bash)
|
|
976
1268
|
|
|
977
1269
|
# 4. Hardcoded fallbacks (last resort)
|
|
@@ -990,21 +1282,20 @@ def find_bash_on_windows():
|
|
|
990
1282
|
return None
|
|
991
1283
|
|
|
992
1284
|
# New helper functions for platform-specific terminal launching
|
|
993
|
-
def get_macos_terminal_argv():
|
|
1285
|
+
def get_macos_terminal_argv() -> list[str]:
|
|
994
1286
|
"""Return macOS Terminal.app launch command as argv list."""
|
|
995
1287
|
return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
|
|
996
1288
|
|
|
997
|
-
def get_windows_terminal_argv():
|
|
1289
|
+
def get_windows_terminal_argv() -> list[str]:
|
|
998
1290
|
"""Return Windows terminal launcher as argv list."""
|
|
999
|
-
bash_exe
|
|
1000
|
-
if not bash_exe:
|
|
1291
|
+
if not (bash_exe := find_bash_on_windows()):
|
|
1001
1292
|
raise Exception(format_error("Git Bash not found"))
|
|
1002
1293
|
|
|
1003
1294
|
if shutil.which('wt'):
|
|
1004
1295
|
return ['wt', bash_exe, '{script}']
|
|
1005
1296
|
return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
|
|
1006
1297
|
|
|
1007
|
-
def get_linux_terminal_argv():
|
|
1298
|
+
def get_linux_terminal_argv() -> list[str] | None:
|
|
1008
1299
|
"""Return first available Linux terminal as argv list."""
|
|
1009
1300
|
terminals = [
|
|
1010
1301
|
('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
|
|
@@ -1023,7 +1314,7 @@ def get_linux_terminal_argv():
|
|
|
1023
1314
|
|
|
1024
1315
|
return None
|
|
1025
1316
|
|
|
1026
|
-
def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
|
|
1317
|
+
def windows_hidden_popen(argv: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None, stdout: Any = None) -> subprocess.Popen:
|
|
1027
1318
|
"""Create hidden Windows process without console window."""
|
|
1028
1319
|
if IS_WINDOWS:
|
|
1029
1320
|
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
|
@@ -1050,7 +1341,7 @@ PLATFORM_TERMINAL_GETTERS = {
|
|
|
1050
1341
|
'Linux': get_linux_terminal_argv,
|
|
1051
1342
|
}
|
|
1052
1343
|
|
|
1053
|
-
def _parse_terminal_command(template, script_file):
|
|
1344
|
+
def _parse_terminal_command(template: str, script_file: str) -> list[str]:
|
|
1054
1345
|
"""Parse terminal command template safely to prevent shell injection.
|
|
1055
1346
|
Parses the template FIRST, then replaces {script} placeholder in the
|
|
1056
1347
|
parsed tokens. This avoids shell injection and handles paths with spaces.
|
|
@@ -1088,7 +1379,7 @@ def _parse_terminal_command(template, script_file):
|
|
|
1088
1379
|
|
|
1089
1380
|
return replaced
|
|
1090
1381
|
|
|
1091
|
-
def launch_terminal(command, env, cwd=None, background=False):
|
|
1382
|
+
def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, background: bool = False) -> str | bool | None:
|
|
1092
1383
|
"""Launch terminal with command using unified script-first approach
|
|
1093
1384
|
Args:
|
|
1094
1385
|
command: Command string from build_claude_command
|
|
@@ -1102,14 +1393,12 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1102
1393
|
|
|
1103
1394
|
# 1) Always create a script
|
|
1104
1395
|
script_file = str(hcom_path(SCRIPTS_DIR,
|
|
1105
|
-
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'
|
|
1106
|
-
ensure_parent=True))
|
|
1396
|
+
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'))
|
|
1107
1397
|
create_bash_script(script_file, env, cwd, command_str, background)
|
|
1108
1398
|
|
|
1109
1399
|
# 2) Background mode
|
|
1110
1400
|
if background:
|
|
1111
1401
|
logs_dir = hcom_path(LOGS_DIR)
|
|
1112
|
-
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
1113
1402
|
log_file = logs_dir / env['HCOM_BACKGROUND']
|
|
1114
1403
|
|
|
1115
1404
|
try:
|
|
@@ -1152,9 +1441,9 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1152
1441
|
return str(log_file)
|
|
1153
1442
|
|
|
1154
1443
|
# 3) Terminal modes
|
|
1155
|
-
terminal_mode =
|
|
1444
|
+
terminal_mode = get_config().terminal
|
|
1156
1445
|
|
|
1157
|
-
if terminal_mode == '
|
|
1446
|
+
if terminal_mode == 'print':
|
|
1158
1447
|
# Print script path and contents
|
|
1159
1448
|
try:
|
|
1160
1449
|
with open(script_file, 'r', encoding='utf-8') as f:
|
|
@@ -1167,7 +1456,7 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1167
1456
|
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
1168
1457
|
return False
|
|
1169
1458
|
|
|
1170
|
-
if terminal_mode == '
|
|
1459
|
+
if terminal_mode == 'here':
|
|
1171
1460
|
print("Launching Claude in current terminal...")
|
|
1172
1461
|
if IS_WINDOWS:
|
|
1173
1462
|
bash_exe = find_bash_on_windows()
|
|
@@ -1179,10 +1468,11 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1179
1468
|
result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
|
|
1180
1469
|
return result.returncode == 0
|
|
1181
1470
|
|
|
1182
|
-
# 4) New window mode
|
|
1183
|
-
|
|
1471
|
+
# 4) New window or custom command mode
|
|
1472
|
+
# If terminal is not 'here' or 'print', it's either 'new' (platform default) or a custom command
|
|
1473
|
+
custom_cmd = None if terminal_mode == 'new' else terminal_mode
|
|
1184
1474
|
|
|
1185
|
-
if not custom_cmd: #
|
|
1475
|
+
if not custom_cmd: # Platform default 'new' mode
|
|
1186
1476
|
if is_termux():
|
|
1187
1477
|
# Keep Termux as special case
|
|
1188
1478
|
am_cmd = [
|
|
@@ -1201,8 +1491,7 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1201
1491
|
|
|
1202
1492
|
# Unified platform handling via helpers
|
|
1203
1493
|
system = platform.system()
|
|
1204
|
-
terminal_getter
|
|
1205
|
-
if not terminal_getter:
|
|
1494
|
+
if not (terminal_getter := PLATFORM_TERMINAL_GETTERS.get(system)):
|
|
1206
1495
|
raise Exception(format_error(f"Unsupported platform: {system}"))
|
|
1207
1496
|
|
|
1208
1497
|
custom_cmd = terminal_getter()
|
|
@@ -1249,18 +1538,30 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1249
1538
|
print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
|
|
1250
1539
|
return False
|
|
1251
1540
|
|
|
1252
|
-
def setup_hooks():
|
|
1253
|
-
"""Set up Claude hooks in
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
settings_path = claude_dir / 'settings.local.json'
|
|
1541
|
+
def setup_hooks() -> bool:
|
|
1542
|
+
"""Set up Claude hooks globally in ~/.claude/settings.json"""
|
|
1543
|
+
|
|
1544
|
+
# TODO: Remove after v0.6.0 - cleanup legacy per-directory hooks
|
|
1258
1545
|
try:
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1546
|
+
positions = load_all_positions()
|
|
1547
|
+
if positions:
|
|
1548
|
+
directories = set()
|
|
1549
|
+
for instance_data in positions.values():
|
|
1550
|
+
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
1551
|
+
directories.add(instance_data['directory'])
|
|
1552
|
+
for directory in directories:
|
|
1553
|
+
if Path(directory).exists():
|
|
1554
|
+
cleanup_directory_hooks(Path(directory))
|
|
1555
|
+
except Exception:
|
|
1556
|
+
pass # Don't fail hook setup if cleanup fails
|
|
1557
|
+
|
|
1558
|
+
# Install to global user settings
|
|
1559
|
+
settings_path = get_claude_settings_path()
|
|
1560
|
+
settings_path.parent.mkdir(exist_ok=True)
|
|
1561
|
+
try:
|
|
1562
|
+
settings = load_settings_json(settings_path, default={})
|
|
1563
|
+
if settings is None:
|
|
1564
|
+
settings = {}
|
|
1264
1565
|
except (json.JSONDecodeError, PermissionError) as e:
|
|
1265
1566
|
raise Exception(format_error(f"Cannot read settings: {e}"))
|
|
1266
1567
|
|
|
@@ -1271,24 +1572,29 @@ def setup_hooks():
|
|
|
1271
1572
|
|
|
1272
1573
|
# Get the hook command template
|
|
1273
1574
|
hook_cmd_base, _ = get_hook_command()
|
|
1274
|
-
|
|
1275
|
-
#
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
# Define all hooks (PostToolUse removed - causes API 400 errors)
|
|
1575
|
+
|
|
1576
|
+
# Define all hooks - must match ACTIVE_HOOK_TYPES
|
|
1577
|
+
# Format: (hook_type, matcher, command, timeout)
|
|
1279
1578
|
hook_configs = [
|
|
1280
1579
|
('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
|
|
1281
1580
|
('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
|
|
1282
1581
|
('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
|
|
1283
|
-
|
|
1284
|
-
('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
|
|
1582
|
+
('Stop', '', f'{hook_cmd_base} poll', 86400), # 24hr timeout max; internal timeout 30min default via config
|
|
1285
1583
|
('Notification', '', f'{hook_cmd_base} notify', None),
|
|
1584
|
+
('SessionEnd', '', f'{hook_cmd_base} sessionend', None),
|
|
1286
1585
|
]
|
|
1287
|
-
|
|
1586
|
+
|
|
1587
|
+
# Validate hook_configs matches ACTIVE_HOOK_TYPES
|
|
1588
|
+
configured_types = [hook_type for hook_type, _, _, _ in hook_configs]
|
|
1589
|
+
if configured_types != ACTIVE_HOOK_TYPES:
|
|
1590
|
+
raise Exception(format_error(
|
|
1591
|
+
f"Hook configuration mismatch: {configured_types} != {ACTIVE_HOOK_TYPES}"
|
|
1592
|
+
))
|
|
1593
|
+
|
|
1288
1594
|
for hook_type, matcher, command, timeout in hook_configs:
|
|
1289
1595
|
if hook_type not in settings['hooks']:
|
|
1290
1596
|
settings['hooks'][hook_type] = []
|
|
1291
|
-
|
|
1597
|
+
|
|
1292
1598
|
hook_dict = {
|
|
1293
1599
|
'matcher': matcher,
|
|
1294
1600
|
'hooks': [{
|
|
@@ -1298,9 +1604,16 @@ def setup_hooks():
|
|
|
1298
1604
|
}
|
|
1299
1605
|
if timeout is not None:
|
|
1300
1606
|
hook_dict['hooks'][0]['timeout'] = timeout
|
|
1301
|
-
|
|
1607
|
+
|
|
1302
1608
|
settings['hooks'][hook_type].append(hook_dict)
|
|
1303
|
-
|
|
1609
|
+
|
|
1610
|
+
# Set $HCOM environment variable for all Claude instances (vanilla + hcom-launched)
|
|
1611
|
+
if 'env' not in settings:
|
|
1612
|
+
settings['env'] = {}
|
|
1613
|
+
|
|
1614
|
+
# Set HCOM based on current execution context (uvx, hcom binary, or full path)
|
|
1615
|
+
settings['env']['HCOM'] = _build_hcom_env_value()
|
|
1616
|
+
|
|
1304
1617
|
# Write settings atomically
|
|
1305
1618
|
try:
|
|
1306
1619
|
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
@@ -1313,104 +1626,50 @@ def setup_hooks():
|
|
|
1313
1626
|
|
|
1314
1627
|
return True
|
|
1315
1628
|
|
|
1316
|
-
def verify_hooks_installed(settings_path):
|
|
1317
|
-
"""Verify that HCOM hooks were installed correctly"""
|
|
1629
|
+
def verify_hooks_installed(settings_path: Path) -> bool:
|
|
1630
|
+
"""Verify that HCOM hooks were installed correctly with correct commands"""
|
|
1318
1631
|
try:
|
|
1319
|
-
settings =
|
|
1320
|
-
settings_path,
|
|
1321
|
-
lambda f: json.load(f),
|
|
1322
|
-
default=None
|
|
1323
|
-
)
|
|
1632
|
+
settings = load_settings_json(settings_path, default=None)
|
|
1324
1633
|
if not settings:
|
|
1325
1634
|
return False
|
|
1326
1635
|
|
|
1327
|
-
# Check all hook types
|
|
1636
|
+
# Check all hook types have correct commands (exactly one HCOM hook per type)
|
|
1328
1637
|
hooks = settings.get('hooks', {})
|
|
1329
|
-
for hook_type
|
|
1330
|
-
|
|
1331
|
-
|
|
1638
|
+
for hook_type, expected_cmd in zip(ACTIVE_HOOK_TYPES, HOOK_COMMANDS):
|
|
1639
|
+
hook_matchers = hooks.get(hook_type, [])
|
|
1640
|
+
if not hook_matchers:
|
|
1641
|
+
return False
|
|
1642
|
+
|
|
1643
|
+
# Count HCOM hooks for this type
|
|
1644
|
+
hcom_hook_count = 0
|
|
1645
|
+
for matcher in hook_matchers:
|
|
1646
|
+
for hook in matcher.get('hooks', []):
|
|
1647
|
+
command = hook.get('command', '')
|
|
1648
|
+
# Check for HCOM and the correct subcommand
|
|
1649
|
+
if ('${HCOM}' in command or 'hcom' in command.lower()) and expected_cmd in command:
|
|
1650
|
+
hcom_hook_count += 1
|
|
1651
|
+
|
|
1652
|
+
# Must have exactly one HCOM hook (not zero, not duplicates)
|
|
1653
|
+
if hcom_hook_count != 1:
|
|
1332
1654
|
return False
|
|
1333
1655
|
|
|
1656
|
+
# Check that HCOM env var is set
|
|
1657
|
+
env = settings.get('env', {})
|
|
1658
|
+
if 'HCOM' not in env:
|
|
1659
|
+
return False
|
|
1660
|
+
|
|
1334
1661
|
return True
|
|
1335
1662
|
except Exception:
|
|
1336
1663
|
return False
|
|
1337
1664
|
|
|
1338
|
-
def is_interactive():
|
|
1665
|
+
def is_interactive() -> bool:
|
|
1339
1666
|
"""Check if running in interactive mode"""
|
|
1340
1667
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
1341
1668
|
|
|
1342
|
-
def get_archive_timestamp():
|
|
1669
|
+
def get_archive_timestamp() -> str:
|
|
1343
1670
|
"""Get timestamp for archive files"""
|
|
1344
1671
|
return datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
1345
1672
|
|
|
1346
|
-
def is_parent_alive(parent_pid=None):
|
|
1347
|
-
"""Check if parent process is alive"""
|
|
1348
|
-
if parent_pid is None:
|
|
1349
|
-
parent_pid = os.getppid()
|
|
1350
|
-
|
|
1351
|
-
# Orphan detection - PID 1 == definitively orphaned
|
|
1352
|
-
if parent_pid == 1:
|
|
1353
|
-
return False
|
|
1354
|
-
|
|
1355
|
-
result = is_process_alive(parent_pid)
|
|
1356
|
-
return result
|
|
1357
|
-
|
|
1358
|
-
def is_process_alive(pid):
|
|
1359
|
-
"""Check if a process with given PID exists - cross-platform"""
|
|
1360
|
-
if pid is None:
|
|
1361
|
-
return False
|
|
1362
|
-
|
|
1363
|
-
try:
|
|
1364
|
-
pid = int(pid)
|
|
1365
|
-
except (TypeError, ValueError):
|
|
1366
|
-
return False
|
|
1367
|
-
|
|
1368
|
-
if IS_WINDOWS:
|
|
1369
|
-
# Windows: Use Windows API to check process existence
|
|
1370
|
-
try:
|
|
1371
|
-
kernel32 = get_windows_kernel32() # Use cached kernel32 instance
|
|
1372
|
-
if not kernel32:
|
|
1373
|
-
return False
|
|
1374
|
-
|
|
1375
|
-
# Try limited permissions first (more likely to succeed on Vista+)
|
|
1376
|
-
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
1377
|
-
error = kernel32.GetLastError()
|
|
1378
|
-
|
|
1379
|
-
if not handle: # Check for None or 0
|
|
1380
|
-
# ERROR_ACCESS_DENIED (5) means process exists but no permission
|
|
1381
|
-
if error == ERROR_ACCESS_DENIED:
|
|
1382
|
-
return True
|
|
1383
|
-
|
|
1384
|
-
# Try fallback with broader permissions for older Windows
|
|
1385
|
-
handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, False, pid)
|
|
1386
|
-
|
|
1387
|
-
if not handle: # Check for None or 0
|
|
1388
|
-
return False # Process doesn't exist or no permission at all
|
|
1389
|
-
|
|
1390
|
-
# Check if process is still running (not just if handle exists)
|
|
1391
|
-
import ctypes.wintypes
|
|
1392
|
-
exit_code = ctypes.wintypes.DWORD()
|
|
1393
|
-
STILL_ACTIVE = 259
|
|
1394
|
-
|
|
1395
|
-
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
1396
|
-
kernel32.CloseHandle(handle)
|
|
1397
|
-
is_still_active = exit_code.value == STILL_ACTIVE
|
|
1398
|
-
return is_still_active
|
|
1399
|
-
|
|
1400
|
-
kernel32.CloseHandle(handle)
|
|
1401
|
-
return False # Couldn't get exit code
|
|
1402
|
-
except Exception:
|
|
1403
|
-
return False
|
|
1404
|
-
else:
|
|
1405
|
-
# Unix: Use os.kill with signal 0
|
|
1406
|
-
try:
|
|
1407
|
-
os.kill(pid, 0)
|
|
1408
|
-
return True
|
|
1409
|
-
except ProcessLookupError:
|
|
1410
|
-
return False
|
|
1411
|
-
except Exception:
|
|
1412
|
-
return False
|
|
1413
|
-
|
|
1414
1673
|
class LogParseResult(NamedTuple):
|
|
1415
1674
|
"""Result from parsing log messages"""
|
|
1416
1675
|
messages: list[dict[str, str]]
|
|
@@ -1465,7 +1724,7 @@ def get_unread_messages(instance_name: str, update_position: bool = False) -> li
|
|
|
1465
1724
|
instance_name: Name of instance to get messages for
|
|
1466
1725
|
update_position: If True, mark messages as read by updating position
|
|
1467
1726
|
"""
|
|
1468
|
-
log_file = hcom_path(LOG_FILE
|
|
1727
|
+
log_file = hcom_path(LOG_FILE)
|
|
1469
1728
|
|
|
1470
1729
|
if not log_file.exists():
|
|
1471
1730
|
return []
|
|
@@ -1512,10 +1771,6 @@ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
1512
1771
|
# Returns: (display_category, formatted_age, status_description)
|
|
1513
1772
|
now = int(time.time())
|
|
1514
1773
|
|
|
1515
|
-
# Check if killed
|
|
1516
|
-
if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
|
|
1517
|
-
return "inactive", "", "killed"
|
|
1518
|
-
|
|
1519
1774
|
# Get last known status
|
|
1520
1775
|
last_status = pos_data.get('last_status', '')
|
|
1521
1776
|
last_status_time = pos_data.get('last_status_time', 0)
|
|
@@ -1529,10 +1784,25 @@ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
1529
1784
|
|
|
1530
1785
|
# Check timeout
|
|
1531
1786
|
age = now - last_status_time
|
|
1532
|
-
timeout = pos_data.get('wait_timeout',
|
|
1787
|
+
timeout = pos_data.get('wait_timeout', get_config().timeout)
|
|
1533
1788
|
if age > timeout:
|
|
1534
1789
|
return "inactive", "", "timeout"
|
|
1535
1790
|
|
|
1791
|
+
# Check Stop hook heartbeat for both blocked-generic and waiting-stale detection
|
|
1792
|
+
last_stop = pos_data.get('last_stop', 0)
|
|
1793
|
+
heartbeat_age = now - last_stop if last_stop else 999999
|
|
1794
|
+
|
|
1795
|
+
# Generic "Claude is waiting for your input" from Notification hook is meaningless
|
|
1796
|
+
# If Stop hook is actively polling (heartbeat < 2s), instance is actually idle
|
|
1797
|
+
if last_status == 'blocked' and last_context == "Claude is waiting for your input" and heartbeat_age < 2:
|
|
1798
|
+
last_status = 'waiting'
|
|
1799
|
+
display_status, desc_template = 'waiting', 'idle'
|
|
1800
|
+
|
|
1801
|
+
# Detect stale 'waiting' status - check heartbeat, not status timestamp
|
|
1802
|
+
if last_status == 'waiting' and heartbeat_age > 2:
|
|
1803
|
+
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1804
|
+
return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
|
|
1805
|
+
|
|
1536
1806
|
# Format description with context if template has {}
|
|
1537
1807
|
if '{}' in desc_template and last_context:
|
|
1538
1808
|
status_desc = desc_template.format(last_context)
|
|
@@ -1548,15 +1818,12 @@ def get_status_block(status_type: str) -> str:
|
|
|
1548
1818
|
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
1549
1819
|
return f"{text_color}{BOLD}{color} {symbol} {RESET}"
|
|
1550
1820
|
|
|
1551
|
-
def format_message_line(msg, truncate=False):
|
|
1821
|
+
def format_message_line(msg: dict[str, str], truncate: bool = False) -> str:
|
|
1552
1822
|
"""Format a message for display"""
|
|
1553
1823
|
time_obj = datetime.fromisoformat(msg['timestamp'])
|
|
1554
1824
|
time_str = time_obj.strftime("%H:%M")
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
sender_emoji = get_config_value('sender_emoji', '🐳')
|
|
1558
|
-
|
|
1559
|
-
display_name = f"{sender_emoji} {msg['from']}" if msg['from'] == sender_name else msg['from']
|
|
1825
|
+
|
|
1826
|
+
display_name = f"{SENDER_EMOJI} {msg['from']}" if msg['from'] == SENDER else msg['from']
|
|
1560
1827
|
|
|
1561
1828
|
if truncate:
|
|
1562
1829
|
sender = display_name[:10]
|
|
@@ -1565,7 +1832,7 @@ def format_message_line(msg, truncate=False):
|
|
|
1565
1832
|
else:
|
|
1566
1833
|
return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
|
|
1567
1834
|
|
|
1568
|
-
def show_recent_messages(messages, limit=None, truncate=False):
|
|
1835
|
+
def show_recent_messages(messages: list[dict[str, str]], limit: int | None = None, truncate: bool = False) -> None:
|
|
1569
1836
|
"""Show recent messages"""
|
|
1570
1837
|
if limit is None:
|
|
1571
1838
|
messages_to_show = messages
|
|
@@ -1577,14 +1844,14 @@ def show_recent_messages(messages, limit=None, truncate=False):
|
|
|
1577
1844
|
print(format_message_line(msg, truncate))
|
|
1578
1845
|
|
|
1579
1846
|
|
|
1580
|
-
def get_terminal_height():
|
|
1847
|
+
def get_terminal_height() -> int:
|
|
1581
1848
|
"""Get current terminal height"""
|
|
1582
1849
|
try:
|
|
1583
1850
|
return shutil.get_terminal_size().lines
|
|
1584
1851
|
except (AttributeError, OSError):
|
|
1585
1852
|
return 24
|
|
1586
1853
|
|
|
1587
|
-
def show_recent_activity_alt_screen(limit=None):
|
|
1854
|
+
def show_recent_activity_alt_screen(limit: int | None = None) -> None:
|
|
1588
1855
|
"""Show recent messages in alt screen format with dynamic height"""
|
|
1589
1856
|
if limit is None:
|
|
1590
1857
|
# Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
|
|
@@ -1596,21 +1863,36 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1596
1863
|
messages = parse_log_messages(log_file).messages
|
|
1597
1864
|
show_recent_messages(messages, limit, truncate=True)
|
|
1598
1865
|
|
|
1599
|
-
def
|
|
1866
|
+
def should_show_in_watch(d: dict[str, Any]) -> bool:
|
|
1867
|
+
"""Show only enabled instances by default"""
|
|
1868
|
+
# Hide disabled instances
|
|
1869
|
+
if not d.get('enabled', False):
|
|
1870
|
+
return False
|
|
1871
|
+
|
|
1872
|
+
# Hide truly ended sessions
|
|
1873
|
+
if d.get('session_ended'):
|
|
1874
|
+
return False
|
|
1875
|
+
|
|
1876
|
+
# Show all other instances (including 'closed' during transition)
|
|
1877
|
+
return True
|
|
1878
|
+
|
|
1879
|
+
def show_instances_by_directory() -> None:
|
|
1600
1880
|
"""Show instances organized by their working directories"""
|
|
1601
1881
|
positions = load_all_positions()
|
|
1602
1882
|
if not positions:
|
|
1603
1883
|
print(f" {DIM}No Claude instances connected{RESET}")
|
|
1604
1884
|
return
|
|
1605
|
-
|
|
1885
|
+
|
|
1606
1886
|
if positions:
|
|
1607
1887
|
directories = {}
|
|
1608
1888
|
for instance_name, pos_data in positions.items():
|
|
1889
|
+
if not should_show_in_watch(pos_data):
|
|
1890
|
+
continue
|
|
1609
1891
|
directory = pos_data.get("directory", "unknown")
|
|
1610
1892
|
if directory not in directories:
|
|
1611
1893
|
directories[directory] = []
|
|
1612
1894
|
directories[directory].append((instance_name, pos_data))
|
|
1613
|
-
|
|
1895
|
+
|
|
1614
1896
|
for directory, instances in directories.items():
|
|
1615
1897
|
print(f" {directory}")
|
|
1616
1898
|
for instance_name, pos_data in instances:
|
|
@@ -1622,7 +1904,7 @@ def show_instances_by_directory():
|
|
|
1622
1904
|
else:
|
|
1623
1905
|
print(f" {DIM}Error reading instance data{RESET}")
|
|
1624
1906
|
|
|
1625
|
-
def alt_screen_detailed_status_and_input():
|
|
1907
|
+
def alt_screen_detailed_status_and_input() -> str:
|
|
1626
1908
|
"""Show detailed status in alt screen and get user input"""
|
|
1627
1909
|
sys.stdout.write("\033[?1049h\033[2J\033[H")
|
|
1628
1910
|
|
|
@@ -1651,7 +1933,7 @@ def alt_screen_detailed_status_and_input():
|
|
|
1651
1933
|
|
|
1652
1934
|
return message
|
|
1653
1935
|
|
|
1654
|
-
def get_status_summary():
|
|
1936
|
+
def get_status_summary() -> str:
|
|
1655
1937
|
"""Get a one-line summary of all instance statuses"""
|
|
1656
1938
|
positions = load_all_positions()
|
|
1657
1939
|
if not positions:
|
|
@@ -1660,6 +1942,9 @@ def get_status_summary():
|
|
|
1660
1942
|
status_counts = {status: 0 for status in STATUS_MAP.keys()}
|
|
1661
1943
|
|
|
1662
1944
|
for _, pos_data in positions.items():
|
|
1945
|
+
# Only count instances that should be shown in watch
|
|
1946
|
+
if not should_show_in_watch(pos_data):
|
|
1947
|
+
continue
|
|
1663
1948
|
status_type, _, _ = get_instance_status(pos_data)
|
|
1664
1949
|
if status_type in status_counts:
|
|
1665
1950
|
status_counts[status_type] += 1
|
|
@@ -1681,30 +1966,43 @@ def get_status_summary():
|
|
|
1681
1966
|
else:
|
|
1682
1967
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1683
1968
|
|
|
1684
|
-
def update_status(s):
|
|
1969
|
+
def update_status(s: str) -> None:
|
|
1685
1970
|
"""Update status line in place"""
|
|
1686
1971
|
sys.stdout.write("\r\033[K" + s)
|
|
1687
1972
|
sys.stdout.flush()
|
|
1688
1973
|
|
|
1689
|
-
def log_line_with_status(message, status):
|
|
1974
|
+
def log_line_with_status(message: str, status: str) -> None:
|
|
1690
1975
|
"""Print message and immediately restore status"""
|
|
1691
1976
|
sys.stdout.write("\r\033[K" + message + "\n")
|
|
1692
1977
|
sys.stdout.write("\033[K" + status)
|
|
1693
1978
|
sys.stdout.flush()
|
|
1694
1979
|
|
|
1695
|
-
def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
1980
|
+
def initialize_instance_in_position_file(instance_name: str, session_id: str | None = None) -> bool:
|
|
1696
1981
|
"""Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
|
|
1697
1982
|
try:
|
|
1698
1983
|
data = load_instance_position(instance_name)
|
|
1699
1984
|
|
|
1985
|
+
# Determine default enabled state: True for hcom-launched, False for vanilla
|
|
1986
|
+
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
1987
|
+
|
|
1988
|
+
# Determine starting position: skip history or read from beginning (or last max_msgs num)
|
|
1989
|
+
initial_pos = 0
|
|
1990
|
+
if SKIP_HISTORY:
|
|
1991
|
+
log_file = hcom_path(LOG_FILE)
|
|
1992
|
+
if log_file.exists():
|
|
1993
|
+
initial_pos = log_file.stat().st_size
|
|
1994
|
+
|
|
1700
1995
|
defaults = {
|
|
1701
|
-
"pos":
|
|
1996
|
+
"pos": initial_pos,
|
|
1997
|
+
"starting_pos": initial_pos,
|
|
1998
|
+
"enabled": is_hcom_launched,
|
|
1702
1999
|
"directory": str(Path.cwd()),
|
|
1703
2000
|
"last_stop": 0,
|
|
1704
2001
|
"session_id": session_id or "",
|
|
1705
2002
|
"transcript_path": "",
|
|
1706
2003
|
"notification_message": "",
|
|
1707
|
-
"alias_announced": False
|
|
2004
|
+
"alias_announced": False,
|
|
2005
|
+
"tag": None
|
|
1708
2006
|
}
|
|
1709
2007
|
|
|
1710
2008
|
# Add missing fields (preserve existing)
|
|
@@ -1715,7 +2013,7 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1715
2013
|
except Exception:
|
|
1716
2014
|
return False
|
|
1717
2015
|
|
|
1718
|
-
def update_instance_position(instance_name, update_fields):
|
|
2016
|
+
def update_instance_position(instance_name: str, update_fields: dict[str, Any]) -> None:
|
|
1719
2017
|
"""Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
|
|
1720
2018
|
try:
|
|
1721
2019
|
data = load_instance_position(instance_name)
|
|
@@ -1734,6 +2032,25 @@ def update_instance_position(instance_name, update_fields):
|
|
|
1734
2032
|
else:
|
|
1735
2033
|
raise
|
|
1736
2034
|
|
|
2035
|
+
def enable_instance(instance_name: str) -> None:
|
|
2036
|
+
"""Enable instance - clears all stop flags and enables Stop hook polling"""
|
|
2037
|
+
update_instance_position(instance_name, {
|
|
2038
|
+
'enabled': True,
|
|
2039
|
+
'force_closed': False,
|
|
2040
|
+
'session_ended': False
|
|
2041
|
+
})
|
|
2042
|
+
set_status(instance_name, 'started')
|
|
2043
|
+
|
|
2044
|
+
def disable_instance(instance_name: str, force: bool = False) -> None:
|
|
2045
|
+
"""Disable instance - stops Stop hook polling"""
|
|
2046
|
+
updates = {
|
|
2047
|
+
'enabled': False
|
|
2048
|
+
}
|
|
2049
|
+
if force:
|
|
2050
|
+
updates['force_closed'] = True
|
|
2051
|
+
update_instance_position(instance_name, updates)
|
|
2052
|
+
set_status(instance_name, 'force_stopped' if force else 'stopped')
|
|
2053
|
+
|
|
1737
2054
|
def set_status(instance_name: str, status: str, context: str = ''):
|
|
1738
2055
|
"""Set instance status event with timestamp"""
|
|
1739
2056
|
update_instance_position(instance_name, {
|
|
@@ -1741,98 +2058,14 @@ def set_status(instance_name: str, status: str, context: str = ''):
|
|
|
1741
2058
|
'last_status_time': int(time.time()),
|
|
1742
2059
|
'last_status_context': context
|
|
1743
2060
|
})
|
|
1744
|
-
log_hook_error(f
|
|
2061
|
+
log_hook_error('set_status', f'Setting status to {status} with context {context} for {instance_name}')
|
|
1745
2062
|
|
|
1746
|
-
|
|
1747
|
-
"""Merge instance data from from_data into to_data."""
|
|
1748
|
-
# Use current session_id from source (overwrites previous)
|
|
1749
|
-
to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
|
|
2063
|
+
# ==================== Command Functions ====================
|
|
1750
2064
|
|
|
1751
|
-
|
|
1752
|
-
to_data['pid'] = os.getppid() # Always use current PID
|
|
1753
|
-
to_data['transcript_path'] = from_data.get('transcript_path', to_data.get('transcript_path', ''))
|
|
1754
|
-
|
|
1755
|
-
# Preserve maximum position
|
|
1756
|
-
to_data['pos'] = max(to_data.get('pos', 0), from_data.get('pos', 0))
|
|
1757
|
-
|
|
1758
|
-
# Update directory to most recent
|
|
1759
|
-
to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
|
|
1760
|
-
|
|
1761
|
-
# Update heartbeat timestamp to most recent
|
|
1762
|
-
to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
|
|
1763
|
-
|
|
1764
|
-
# Merge new status fields - take most recent status event
|
|
1765
|
-
from_time = from_data.get('last_status_time', 0)
|
|
1766
|
-
to_time = to_data.get('last_status_time', 0)
|
|
1767
|
-
if from_time > to_time:
|
|
1768
|
-
to_data['last_status'] = from_data.get('last_status', '')
|
|
1769
|
-
to_data['last_status_time'] = from_time
|
|
1770
|
-
to_data['last_status_context'] = from_data.get('last_status_context', '')
|
|
1771
|
-
|
|
1772
|
-
# Preserve background mode if set
|
|
1773
|
-
to_data['background'] = to_data.get('background') or from_data.get('background')
|
|
1774
|
-
if from_data.get('background_log_file'):
|
|
1775
|
-
to_data['background_log_file'] = from_data['background_log_file']
|
|
1776
|
-
|
|
1777
|
-
return to_data
|
|
1778
|
-
|
|
1779
|
-
def terminate_process(pid, force=False):
|
|
1780
|
-
"""Cross-platform process termination"""
|
|
1781
|
-
try:
|
|
1782
|
-
if IS_WINDOWS:
|
|
1783
|
-
cmd = ['taskkill', '/PID', str(pid)]
|
|
1784
|
-
if force:
|
|
1785
|
-
cmd.insert(1, '/F')
|
|
1786
|
-
subprocess.run(cmd, capture_output=True, check=True)
|
|
1787
|
-
else:
|
|
1788
|
-
os.kill(pid, 9 if force else 15) # SIGKILL or SIGTERM
|
|
1789
|
-
return True
|
|
1790
|
-
except (ProcessLookupError, OSError, subprocess.CalledProcessError):
|
|
1791
|
-
return False # Process already dead
|
|
1792
|
-
|
|
1793
|
-
def merge_instance_immediately(from_name, to_name):
|
|
1794
|
-
"""Merge from_name into to_name with safety checks. Returns success message or error message."""
|
|
1795
|
-
if from_name == to_name:
|
|
1796
|
-
return ""
|
|
1797
|
-
|
|
1798
|
-
try:
|
|
1799
|
-
from_data = load_instance_position(from_name)
|
|
1800
|
-
to_data = load_instance_position(to_name)
|
|
1801
|
-
|
|
1802
|
-
# Check if target has recent activity (time-based check instead of PID)
|
|
1803
|
-
now = time.time()
|
|
1804
|
-
last_activity = max(
|
|
1805
|
-
to_data.get('last_stop', 0),
|
|
1806
|
-
to_data.get('last_status_time', 0)
|
|
1807
|
-
)
|
|
1808
|
-
time_since_activity = now - last_activity
|
|
1809
|
-
if time_since_activity < MERGE_ACTIVITY_THRESHOLD:
|
|
1810
|
-
return f"Cannot recover {to_name}: instance is active (activity {int(time_since_activity)}s ago)"
|
|
1811
|
-
|
|
1812
|
-
# Merge data using helper
|
|
1813
|
-
to_data = merge_instance_data(to_data, from_data)
|
|
1814
|
-
|
|
1815
|
-
# Save merged data - check for success
|
|
1816
|
-
if not save_instance_position(to_name, to_data):
|
|
1817
|
-
return f"Failed to save merged data for {to_name}"
|
|
1818
|
-
|
|
1819
|
-
# Cleanup source file only after successful save
|
|
1820
|
-
try:
|
|
1821
|
-
hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
|
|
1822
|
-
except (FileNotFoundError, PermissionError, OSError):
|
|
1823
|
-
pass # Non-critical if cleanup fails
|
|
1824
|
-
|
|
1825
|
-
return f"[SUCCESS] ✓ Recovered alias: {to_name}"
|
|
1826
|
-
except Exception:
|
|
1827
|
-
return f"Failed to recover alias: {to_name}"
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
# ==================== Command Functions ====================
|
|
1831
|
-
|
|
1832
|
-
def show_main_screen_header():
|
|
2065
|
+
def show_main_screen_header() -> list[dict[str, str]]:
|
|
1833
2066
|
"""Show header for main screen"""
|
|
1834
2067
|
sys.stdout.write("\033[2J\033[H")
|
|
1835
|
-
|
|
2068
|
+
|
|
1836
2069
|
log_file = hcom_path(LOG_FILE)
|
|
1837
2070
|
all_messages = []
|
|
1838
2071
|
if log_file.exists():
|
|
@@ -1843,42 +2076,9 @@ def show_main_screen_header():
|
|
|
1843
2076
|
|
|
1844
2077
|
return all_messages
|
|
1845
2078
|
|
|
1846
|
-
def
|
|
1847
|
-
"""Show CLI hints if configured"""
|
|
1848
|
-
cli_hints = get_config_value('cli_hints', '')
|
|
1849
|
-
if cli_hints:
|
|
1850
|
-
if to_stderr:
|
|
1851
|
-
print(f"\n{cli_hints}", file=sys.stderr)
|
|
1852
|
-
else:
|
|
1853
|
-
print(f"\n{cli_hints}")
|
|
1854
|
-
|
|
1855
|
-
def cmd_help():
|
|
2079
|
+
def cmd_help() -> int:
|
|
1856
2080
|
"""Show help text"""
|
|
1857
|
-
|
|
1858
|
-
print("""hcom - Claude Hook Comms
|
|
1859
|
-
|
|
1860
|
-
Usage:
|
|
1861
|
-
hcom open [n] Launch n Claude instances
|
|
1862
|
-
hcom open <agent> Launch named agent from .claude/agents/
|
|
1863
|
-
hcom open --prefix <team> n Launch n instances with team prefix
|
|
1864
|
-
hcom open --background Launch instances as background processes (-p also works)
|
|
1865
|
-
hcom open --claude-args "--model sonnet" Pass claude code CLI flags
|
|
1866
|
-
hcom watch View conversation dashboard
|
|
1867
|
-
hcom clear Clear and archive conversation
|
|
1868
|
-
hcom cleanup Remove hooks from current directory
|
|
1869
|
-
hcom cleanup --all Remove hooks from all tracked directories
|
|
1870
|
-
hcom kill [instance alias] Kill specific instance
|
|
1871
|
-
hcom kill --all Kill all running instances
|
|
1872
|
-
hcom help Show this help
|
|
1873
|
-
|
|
1874
|
-
Automation:
|
|
1875
|
-
hcom send 'msg' Send message to all
|
|
1876
|
-
hcom send '@prefix msg' Send to specific instances
|
|
1877
|
-
hcom watch --logs Show conversation log
|
|
1878
|
-
hcom watch --status Show status of instances
|
|
1879
|
-
hcom watch --wait [seconds] Wait for new messages (default 60s)
|
|
1880
|
-
|
|
1881
|
-
Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
|
|
2081
|
+
print(HELP_TEXT)
|
|
1882
2082
|
|
|
1883
2083
|
# Additional help for AI assistants
|
|
1884
2084
|
if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
|
|
@@ -1888,26 +2088,26 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
|
|
|
1888
2088
|
|
|
1889
2089
|
CONCEPT: HCOM launches Claude Code instances in new terminal windows.
|
|
1890
2090
|
They communicate with each other via a shared conversation.
|
|
1891
|
-
You communicate with them via hcom
|
|
2091
|
+
You communicate with them via hcom commands.
|
|
1892
2092
|
|
|
1893
2093
|
KEY UNDERSTANDING:
|
|
1894
2094
|
• Single conversation - All instances share ~/.hcom/hcom.log
|
|
1895
|
-
• Messaging -
|
|
1896
|
-
• Instances receive messages via hooks automatically
|
|
2095
|
+
• Messaging - CLI and instances send with hcom send "message"
|
|
2096
|
+
• Instances receive messages via hooks automatically
|
|
1897
2097
|
• hcom open is directory-specific - always cd to project directory first
|
|
1898
|
-
•
|
|
1899
|
-
|
|
1900
|
-
•
|
|
1901
|
-
"reviewer" named agent loads .claude/agents/reviewer.md (if it was ever created)
|
|
2098
|
+
• Named agents are custom system prompt files created by users/claude code beforehand.
|
|
2099
|
+
• Named agents load from .claude/agents/<name>.md - if they have been created
|
|
2100
|
+
• hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
|
|
1902
2101
|
|
|
1903
2102
|
LAUNCH PATTERNS:
|
|
1904
|
-
hcom
|
|
1905
|
-
hcom
|
|
1906
|
-
hcom
|
|
1907
|
-
hcom
|
|
1908
|
-
hcom
|
|
1909
|
-
hcom
|
|
1910
|
-
|
|
2103
|
+
hcom 2 claude # 2 generic instances
|
|
2104
|
+
hcom claude --model sonnet # 1 instance with sonnet model
|
|
2105
|
+
hcom 3 claude -p "task" # 3 instances in background with prompt
|
|
2106
|
+
HCOM_AGENT=reviewer hcom 3 claude # 3 reviewer instances (agent file must exist)
|
|
2107
|
+
HCOM_TAG=api hcom 2 claude # Team naming: api-hova7, api-kolec
|
|
2108
|
+
HCOM_AGENT=reviewer,tester hcom 2 claude # 2 reviewers + 2 testers
|
|
2109
|
+
hcom claude --resume <sessionid> # Resume specific session
|
|
2110
|
+
HCOM_PROMPT="task" hcom claude # Set initial prompt for instance
|
|
1911
2111
|
|
|
1912
2112
|
@MENTION TARGETING:
|
|
1913
2113
|
hcom send "message" # Broadcasts to everyone
|
|
@@ -1916,144 +2116,161 @@ LAUNCH PATTERNS:
|
|
|
1916
2116
|
(Unmatched @mentions broadcast to everyone)
|
|
1917
2117
|
|
|
1918
2118
|
STATUS INDICATORS:
|
|
1919
|
-
• ▶ active - instance
|
|
1920
|
-
•
|
|
1921
|
-
•
|
|
1922
|
-
• ■ blocked - instance is blocked by permission request (needs user approval)
|
|
1923
|
-
• ○ inactive - instance is timed out, disconnected, etc
|
|
1924
|
-
• ○ unknown - no status information available
|
|
2119
|
+
• ▶ active - processing/executing • ▷ delivered - instance just received a message
|
|
2120
|
+
• ◉ idle - waiting for new messages • ■ blocked - permission request (needs user approval)
|
|
2121
|
+
• ○ inactive - timed out, disconnected, etc • ○ unknown
|
|
1925
2122
|
|
|
1926
2123
|
CONFIG:
|
|
1927
|
-
Config file
|
|
2124
|
+
Config file: ~/.hcom/config.env (KEY=VALUE format)
|
|
1928
2125
|
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2126
|
+
Environment variables (override config file):
|
|
2127
|
+
HCOM_TERMINAL="new" (default) | "here" | "print" | "kitty -e {script}" (custom)
|
|
2128
|
+
HCOM_PROMPT="say hi in hcom chat"
|
|
2129
|
+
HCOM_HINTS="text" # Extra info appended to all messages sent to instances
|
|
2130
|
+
HCOM_TAG="api" # Group instances under api-* names
|
|
2131
|
+
HCOM_AGENT="reviewer" # Launch with agent (comma-separated for multiple)
|
|
1934
2132
|
|
|
1935
|
-
Temporary environment overrides for any setting (all caps & append HCOM_):
|
|
1936
|
-
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages received by instance
|
|
1937
|
-
export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
|
|
1938
2133
|
|
|
1939
2134
|
EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
|
|
1940
2135
|
with 'hcom watch --status'. Instances respond automatically in shared chat.
|
|
1941
2136
|
|
|
1942
2137
|
Run 'claude --help' to see all claude code CLI flags.""")
|
|
1943
2138
|
|
|
1944
|
-
show_cli_hints(to_stderr=False)
|
|
1945
2139
|
else:
|
|
1946
2140
|
if not IS_WINDOWS:
|
|
1947
2141
|
print("\nFor additional info & examples: hcom --help | cat")
|
|
1948
2142
|
|
|
1949
2143
|
return 0
|
|
1950
2144
|
|
|
1951
|
-
def
|
|
1952
|
-
"""Launch Claude instances
|
|
2145
|
+
def cmd_launch(argv: list[str]) -> int:
|
|
2146
|
+
"""Launch Claude instances: hcom [N] [claude] [args]"""
|
|
1953
2147
|
try:
|
|
1954
|
-
# Parse arguments
|
|
1955
|
-
|
|
2148
|
+
# Parse arguments: hcom [N] [claude] [args]
|
|
2149
|
+
count = 1
|
|
2150
|
+
forwarded = []
|
|
2151
|
+
|
|
2152
|
+
# Extract count if first arg is digit
|
|
2153
|
+
if argv and argv[0].isdigit():
|
|
2154
|
+
count = int(argv[0])
|
|
2155
|
+
if count <= 0:
|
|
2156
|
+
raise CLIError('Count must be positive.')
|
|
2157
|
+
if count > 100:
|
|
2158
|
+
raise CLIError('Too many instances requested (max 100).')
|
|
2159
|
+
argv = argv[1:]
|
|
2160
|
+
|
|
2161
|
+
# Skip 'claude' keyword if present
|
|
2162
|
+
if argv and argv[0] == 'claude':
|
|
2163
|
+
argv = argv[1:]
|
|
2164
|
+
|
|
2165
|
+
# Forward all remaining args to claude CLI
|
|
2166
|
+
forwarded = argv
|
|
2167
|
+
|
|
2168
|
+
# Get tag from config
|
|
2169
|
+
tag = get_config().tag
|
|
2170
|
+
if tag and '|' in tag:
|
|
2171
|
+
raise CLIError('Tag cannot contain "|" characters.')
|
|
2172
|
+
|
|
2173
|
+
# Get agents from config (comma-separated)
|
|
2174
|
+
agent_env = get_config().agent
|
|
2175
|
+
agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['generic']
|
|
2176
|
+
|
|
2177
|
+
# Detect background mode from -p/--print flags in forwarded args
|
|
2178
|
+
background = '-p' in forwarded or '--print' in forwarded
|
|
1956
2179
|
|
|
1957
2180
|
# Add -p flag and stream-json output for background mode if not already present
|
|
2181
|
+
claude_args = forwarded
|
|
1958
2182
|
if background and '-p' not in claude_args and '--print' not in claude_args:
|
|
1959
2183
|
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
1960
|
-
|
|
1961
|
-
terminal_mode =
|
|
1962
|
-
|
|
1963
|
-
#
|
|
1964
|
-
|
|
2184
|
+
|
|
2185
|
+
terminal_mode = get_config().terminal
|
|
2186
|
+
|
|
2187
|
+
# Calculate total instances to launch
|
|
2188
|
+
total_instances = count * len(agents)
|
|
2189
|
+
|
|
2190
|
+
# Fail fast for here mode with multiple instances
|
|
2191
|
+
if terminal_mode == 'here' and total_instances > 1:
|
|
1965
2192
|
print(format_error(
|
|
1966
|
-
f"
|
|
1967
|
-
"Use 'hcom
|
|
2193
|
+
f"'here' mode cannot launch {total_instances} instances (it's one terminal window)",
|
|
2194
|
+
"Use 'hcom 1' for one generic instance"
|
|
1968
2195
|
), file=sys.stderr)
|
|
1969
2196
|
return 1
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
setup_hooks()
|
|
1973
|
-
except Exception as e:
|
|
1974
|
-
print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
|
|
1975
|
-
return 1
|
|
1976
|
-
|
|
1977
|
-
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
2197
|
+
|
|
2198
|
+
log_file = hcom_path(LOG_FILE)
|
|
1978
2199
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1979
|
-
|
|
1980
|
-
|
|
2200
|
+
|
|
1981
2201
|
if not log_file.exists():
|
|
1982
2202
|
log_file.touch()
|
|
1983
|
-
|
|
2203
|
+
|
|
1984
2204
|
# Build environment variables for Claude instances
|
|
1985
2205
|
base_env = build_claude_env()
|
|
1986
2206
|
|
|
1987
|
-
# Add
|
|
1988
|
-
if
|
|
1989
|
-
base_env['
|
|
1990
|
-
|
|
1991
|
-
hint = f"To respond to {prefix} group: {send_cmd} '@{prefix} message'"
|
|
1992
|
-
base_env['HCOM_INSTANCE_HINTS'] = hint
|
|
1993
|
-
first_use = f"You're in the {prefix} group. Use {prefix} to message: {send_cmd} '@{prefix} message'"
|
|
1994
|
-
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
1995
|
-
|
|
2207
|
+
# Add tag-specific hints if provided
|
|
2208
|
+
if tag:
|
|
2209
|
+
base_env['HCOM_TAG'] = tag
|
|
2210
|
+
|
|
1996
2211
|
launched = 0
|
|
1997
|
-
initial_prompt =
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
#
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
#
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
claude_args=claude_args,
|
|
2018
|
-
initial_prompt=initial_prompt
|
|
2019
|
-
)
|
|
2020
|
-
else:
|
|
2021
|
-
# Agent instance
|
|
2022
|
-
try:
|
|
2023
|
-
agent_content, agent_config = resolve_agent(instance_type)
|
|
2024
|
-
# Mark this as a subagent instance for SessionStart hook
|
|
2025
|
-
instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
|
|
2026
|
-
# Prepend agent instance awareness to system prompt
|
|
2027
|
-
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
2028
|
-
agent_content = agent_prefix + agent_content
|
|
2029
|
-
# Use agent's model and tools if specified and not overridden in claude_args
|
|
2030
|
-
agent_model = agent_config.get('model')
|
|
2031
|
-
agent_tools = agent_config.get('tools')
|
|
2212
|
+
initial_prompt = get_config().prompt
|
|
2213
|
+
|
|
2214
|
+
# Launch count instances of each agent
|
|
2215
|
+
for agent in agents:
|
|
2216
|
+
for _ in range(count):
|
|
2217
|
+
instance_type = agent
|
|
2218
|
+
instance_env = base_env.copy()
|
|
2219
|
+
|
|
2220
|
+
# Mark all hcom-launched instances
|
|
2221
|
+
instance_env['HCOM_LAUNCHED'] = '1'
|
|
2222
|
+
|
|
2223
|
+
# Mark background instances via environment with log filename
|
|
2224
|
+
if background:
|
|
2225
|
+
# Generate unique log filename
|
|
2226
|
+
log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
|
|
2227
|
+
instance_env['HCOM_BACKGROUND'] = log_filename
|
|
2228
|
+
|
|
2229
|
+
# Build claude command
|
|
2230
|
+
if instance_type == 'generic':
|
|
2231
|
+
# Generic instance - no agent content
|
|
2032
2232
|
claude_cmd, _ = build_claude_command(
|
|
2033
|
-
agent_content=
|
|
2233
|
+
agent_content=None,
|
|
2034
2234
|
claude_args=claude_args,
|
|
2035
|
-
initial_prompt=initial_prompt
|
|
2036
|
-
model=agent_model,
|
|
2037
|
-
tools=agent_tools
|
|
2235
|
+
initial_prompt=initial_prompt
|
|
2038
2236
|
)
|
|
2039
|
-
# Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
|
|
2040
|
-
except (FileNotFoundError, ValueError) as e:
|
|
2041
|
-
print(str(e), file=sys.stderr)
|
|
2042
|
-
continue
|
|
2043
|
-
|
|
2044
|
-
try:
|
|
2045
|
-
if background:
|
|
2046
|
-
log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
|
|
2047
|
-
if log_file:
|
|
2048
|
-
print(f"Background instance launched, log: {log_file}")
|
|
2049
|
-
launched += 1
|
|
2050
2237
|
else:
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2238
|
+
# Agent instance
|
|
2239
|
+
try:
|
|
2240
|
+
agent_content, agent_config = resolve_agent(instance_type)
|
|
2241
|
+
# Mark this as a subagent instance for SessionStart hook
|
|
2242
|
+
instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
|
|
2243
|
+
# Prepend agent instance awareness to system prompt
|
|
2244
|
+
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
2245
|
+
agent_content = agent_prefix + agent_content
|
|
2246
|
+
# Use agent's model and tools if specified and not overridden in claude_args
|
|
2247
|
+
agent_model = agent_config.get('model')
|
|
2248
|
+
agent_tools = agent_config.get('tools')
|
|
2249
|
+
claude_cmd, _ = build_claude_command(
|
|
2250
|
+
agent_content=agent_content,
|
|
2251
|
+
claude_args=claude_args,
|
|
2252
|
+
initial_prompt=initial_prompt,
|
|
2253
|
+
model=agent_model,
|
|
2254
|
+
tools=agent_tools
|
|
2255
|
+
)
|
|
2256
|
+
# Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
|
|
2257
|
+
except (FileNotFoundError, ValueError) as e:
|
|
2258
|
+
print(str(e), file=sys.stderr)
|
|
2259
|
+
continue
|
|
2260
|
+
|
|
2261
|
+
try:
|
|
2262
|
+
if background:
|
|
2263
|
+
log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
|
|
2264
|
+
if log_file:
|
|
2265
|
+
print(f"Background instance launched, log: {log_file}")
|
|
2266
|
+
launched += 1
|
|
2267
|
+
else:
|
|
2268
|
+
if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
|
|
2269
|
+
launched += 1
|
|
2270
|
+
except Exception as e:
|
|
2271
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
2272
|
+
|
|
2273
|
+
requested = total_instances
|
|
2057
2274
|
failed = requested - launched
|
|
2058
2275
|
|
|
2059
2276
|
if launched == 0:
|
|
@@ -2066,36 +2283,31 @@ def cmd_open(*args):
|
|
|
2066
2283
|
else:
|
|
2067
2284
|
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
2068
2285
|
|
|
2069
|
-
# Auto-launch watch dashboard if
|
|
2070
|
-
terminal_mode =
|
|
2071
|
-
auto_watch = get_config_value('auto_watch', True)
|
|
2286
|
+
# Auto-launch watch dashboard if in new window mode (new or custom) and all instances launched successfully
|
|
2287
|
+
terminal_mode = get_config().terminal
|
|
2072
2288
|
|
|
2073
|
-
# Only auto-watch if ALL instances launched successfully
|
|
2074
|
-
if terminal_mode
|
|
2289
|
+
# Only auto-watch if ALL instances launched successfully and launches windows (not 'here' or 'print')
|
|
2290
|
+
if terminal_mode not in ('here', 'print') and failed == 0 and is_interactive():
|
|
2075
2291
|
# Show tips first if needed
|
|
2076
|
-
if
|
|
2077
|
-
print(f"\n • Send to {
|
|
2292
|
+
if tag:
|
|
2293
|
+
print(f"\n • Send to {tag} team: hcom send '@{tag} message'")
|
|
2078
2294
|
|
|
2079
2295
|
# Clear transition message
|
|
2080
2296
|
print("\nOpening hcom watch...")
|
|
2081
2297
|
time.sleep(2) # Brief pause so user sees the message
|
|
2082
2298
|
|
|
2083
2299
|
# Launch interactive watch dashboard in current terminal
|
|
2084
|
-
return cmd_watch()
|
|
2300
|
+
return cmd_watch([]) # Empty argv = interactive mode
|
|
2085
2301
|
else:
|
|
2086
2302
|
tips = [
|
|
2087
2303
|
"Run 'hcom watch' to view/send in conversation dashboard",
|
|
2088
2304
|
]
|
|
2089
|
-
if
|
|
2090
|
-
tips.append(f"Send to {
|
|
2305
|
+
if tag:
|
|
2306
|
+
tips.append(f"Send to {tag} team: hcom send '@{tag} message'")
|
|
2091
2307
|
|
|
2092
2308
|
if tips:
|
|
2093
2309
|
print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
|
|
2094
2310
|
|
|
2095
|
-
# Show cli_hints if configured (non-interactive mode)
|
|
2096
|
-
if not is_interactive():
|
|
2097
|
-
show_cli_hints(to_stderr=False)
|
|
2098
|
-
|
|
2099
2311
|
return 0
|
|
2100
2312
|
|
|
2101
2313
|
except ValueError as e:
|
|
@@ -2105,40 +2317,45 @@ def cmd_open(*args):
|
|
|
2105
2317
|
print(str(e), file=sys.stderr)
|
|
2106
2318
|
return 1
|
|
2107
2319
|
|
|
2108
|
-
def cmd_watch(
|
|
2109
|
-
"""View conversation dashboard"""
|
|
2320
|
+
def cmd_watch(argv: list[str]) -> int:
|
|
2321
|
+
"""View conversation dashboard: hcom watch [--logs|--status|--wait [SEC]]"""
|
|
2322
|
+
# Extract launch flag for external terminals (used by claude code bootstrap)
|
|
2323
|
+
cleaned_args: list[str] = []
|
|
2324
|
+
for arg in argv:
|
|
2325
|
+
if arg == '--launch':
|
|
2326
|
+
watch_cmd = f"{build_hcom_command()} watch"
|
|
2327
|
+
result = launch_terminal(watch_cmd, build_claude_env(), cwd=os.getcwd())
|
|
2328
|
+
return 0 if result else 1
|
|
2329
|
+
else:
|
|
2330
|
+
cleaned_args.append(arg)
|
|
2331
|
+
argv = cleaned_args
|
|
2332
|
+
|
|
2333
|
+
# Parse arguments
|
|
2334
|
+
show_logs = '--logs' in argv
|
|
2335
|
+
show_status = '--status' in argv
|
|
2336
|
+
wait_timeout = None
|
|
2337
|
+
|
|
2338
|
+
# Check for --wait flag
|
|
2339
|
+
if '--wait' in argv:
|
|
2340
|
+
idx = argv.index('--wait')
|
|
2341
|
+
if idx + 1 < len(argv):
|
|
2342
|
+
try:
|
|
2343
|
+
wait_timeout = int(argv[idx + 1])
|
|
2344
|
+
if wait_timeout < 0:
|
|
2345
|
+
raise CLIError('--wait expects a non-negative number of seconds.')
|
|
2346
|
+
except ValueError:
|
|
2347
|
+
wait_timeout = 60 # Default for non-numeric values
|
|
2348
|
+
else:
|
|
2349
|
+
wait_timeout = 60 # Default timeout
|
|
2350
|
+
show_logs = True # --wait implies logs mode
|
|
2351
|
+
|
|
2110
2352
|
log_file = hcom_path(LOG_FILE)
|
|
2111
2353
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2112
|
-
|
|
2354
|
+
|
|
2113
2355
|
if not log_file.exists() and not instances_dir.exists():
|
|
2114
|
-
print(format_error("No conversation log found", "Run 'hcom
|
|
2356
|
+
print(format_error("No conversation log found", "Run 'hcom' first"), file=sys.stderr)
|
|
2115
2357
|
return 1
|
|
2116
|
-
|
|
2117
|
-
# Parse arguments
|
|
2118
|
-
show_logs = False
|
|
2119
|
-
show_status = False
|
|
2120
|
-
wait_timeout = None
|
|
2121
|
-
|
|
2122
|
-
i = 0
|
|
2123
|
-
while i < len(args):
|
|
2124
|
-
arg = args[i]
|
|
2125
|
-
if arg == '--logs':
|
|
2126
|
-
show_logs = True
|
|
2127
|
-
elif arg == '--status':
|
|
2128
|
-
show_status = True
|
|
2129
|
-
elif arg == '--wait':
|
|
2130
|
-
# Check if next arg is a number
|
|
2131
|
-
if i + 1 < len(args) and args[i + 1].isdigit():
|
|
2132
|
-
wait_timeout = int(args[i + 1])
|
|
2133
|
-
i += 1 # Skip the number
|
|
2134
|
-
else:
|
|
2135
|
-
wait_timeout = 60 # Default
|
|
2136
|
-
i += 1
|
|
2137
|
-
|
|
2138
|
-
# If wait is specified, enable logs to show the messages
|
|
2139
|
-
if wait_timeout is not None:
|
|
2140
|
-
show_logs = True
|
|
2141
|
-
|
|
2358
|
+
|
|
2142
2359
|
# Non-interactive mode (no TTY or flags specified)
|
|
2143
2360
|
if not is_interactive() or show_logs or show_status:
|
|
2144
2361
|
if show_logs:
|
|
@@ -2150,14 +2367,16 @@ def cmd_watch(*args):
|
|
|
2150
2367
|
last_pos = 0
|
|
2151
2368
|
messages = []
|
|
2152
2369
|
|
|
2153
|
-
# If --wait, show
|
|
2370
|
+
# If --wait, show recent messages (max of: last 3 messages OR all messages in last 5 seconds)
|
|
2154
2371
|
if wait_timeout is not None:
|
|
2155
2372
|
cutoff = datetime.now() - timedelta(seconds=5)
|
|
2156
|
-
|
|
2157
|
-
|
|
2373
|
+
recent_by_time = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
|
|
2374
|
+
last_three = messages[-3:] if len(messages) >= 3 else messages
|
|
2375
|
+
# Show whichever is larger: recent by time or last 3
|
|
2376
|
+
recent_messages = recent_by_time if len(recent_by_time) > len(last_three) else last_three
|
|
2158
2377
|
# Status to stderr, data to stdout
|
|
2159
2378
|
if recent_messages:
|
|
2160
|
-
print(f'---Showing
|
|
2379
|
+
print(f'---Showing recent messages---', file=sys.stderr)
|
|
2161
2380
|
for msg in recent_messages:
|
|
2162
2381
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2163
2382
|
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
@@ -2195,7 +2414,6 @@ def cmd_watch(*args):
|
|
|
2195
2414
|
else:
|
|
2196
2415
|
print("No messages yet", file=sys.stderr)
|
|
2197
2416
|
|
|
2198
|
-
show_cli_hints()
|
|
2199
2417
|
|
|
2200
2418
|
elif show_status:
|
|
2201
2419
|
# Build JSON output
|
|
@@ -2205,6 +2423,8 @@ def cmd_watch(*args):
|
|
|
2205
2423
|
status_counts = {}
|
|
2206
2424
|
|
|
2207
2425
|
for name, data in positions.items():
|
|
2426
|
+
if not should_show_in_watch(data):
|
|
2427
|
+
continue
|
|
2208
2428
|
status, age, _ = get_instance_status(data)
|
|
2209
2429
|
instances[name] = {
|
|
2210
2430
|
"status": status,
|
|
@@ -2214,7 +2434,6 @@ def cmd_watch(*args):
|
|
|
2214
2434
|
"last_status": data.get("last_status", ""),
|
|
2215
2435
|
"last_status_time": data.get("last_status_time", 0),
|
|
2216
2436
|
"last_status_context": data.get("last_status_context", ""),
|
|
2217
|
-
"pid": data.get("pid"),
|
|
2218
2437
|
"background": bool(data.get("background"))
|
|
2219
2438
|
}
|
|
2220
2439
|
status_counts[status] = status_counts.get(status, 0) + 1
|
|
@@ -2235,16 +2454,14 @@ def cmd_watch(*args):
|
|
|
2235
2454
|
}
|
|
2236
2455
|
|
|
2237
2456
|
print(json.dumps(output, indent=2))
|
|
2238
|
-
show_cli_hints()
|
|
2239
2457
|
else:
|
|
2240
2458
|
print("No TTY - Automation usage:", file=sys.stderr)
|
|
2241
|
-
print(" hcom send 'message' Send message to chat", file=sys.stderr)
|
|
2242
2459
|
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
2243
2460
|
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
2244
2461
|
print(" hcom watch --wait Wait for new messages", file=sys.stderr)
|
|
2462
|
+
print(" hcom watch --launch Launch interactive dashboard in new terminal", file=sys.stderr)
|
|
2245
2463
|
print(" Full information: hcom --help")
|
|
2246
2464
|
|
|
2247
|
-
show_cli_hints()
|
|
2248
2465
|
|
|
2249
2466
|
return 0
|
|
2250
2467
|
|
|
@@ -2320,10 +2537,9 @@ def cmd_watch(*args):
|
|
|
2320
2537
|
last_pos = log_file.stat().st_size
|
|
2321
2538
|
|
|
2322
2539
|
if message and message.strip():
|
|
2323
|
-
|
|
2324
|
-
send_message(sender_name, message.strip())
|
|
2540
|
+
send_cli(message.strip(), quiet=True)
|
|
2325
2541
|
print(f"{FG_GREEN}✓ Sent{RESET}")
|
|
2326
|
-
|
|
2542
|
+
|
|
2327
2543
|
print()
|
|
2328
2544
|
|
|
2329
2545
|
current_status = get_status_summary()
|
|
@@ -2337,37 +2553,24 @@ def cmd_watch(*args):
|
|
|
2337
2553
|
|
|
2338
2554
|
return 0
|
|
2339
2555
|
|
|
2340
|
-
def
|
|
2556
|
+
def clear() -> int:
|
|
2341
2557
|
"""Clear and archive conversation"""
|
|
2342
|
-
log_file = hcom_path(LOG_FILE
|
|
2558
|
+
log_file = hcom_path(LOG_FILE)
|
|
2343
2559
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2344
2560
|
archive_folder = hcom_path(ARCHIVE_DIR)
|
|
2345
|
-
archive_folder.mkdir(exist_ok=True)
|
|
2346
2561
|
|
|
2347
|
-
#
|
|
2562
|
+
# cleanup: temp files, old scripts, old outbox files
|
|
2563
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2348
2564
|
if instances_dir.exists():
|
|
2349
|
-
|
|
2350
|
-
if deleted_count > 0:
|
|
2351
|
-
print(f"Cleaned up {deleted_count} temp files")
|
|
2565
|
+
sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
|
|
2352
2566
|
|
|
2353
|
-
# Clean up old script files (older than 24 hours)
|
|
2354
2567
|
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
2355
2568
|
if scripts_dir.exists():
|
|
2356
|
-
|
|
2357
|
-
script_count = sum(1 for f in scripts_dir.glob('*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
|
|
2358
|
-
if script_count > 0:
|
|
2359
|
-
print(f"Cleaned up {script_count} old script files")
|
|
2360
|
-
|
|
2361
|
-
# Clean up old launch mapping files (older than 24 hours)
|
|
2362
|
-
if instances_dir.exists():
|
|
2363
|
-
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2364
|
-
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)
|
|
2365
|
-
if mapping_count > 0:
|
|
2366
|
-
print(f"Cleaned up {mapping_count} old launch mapping files")
|
|
2569
|
+
sum(1 for f in scripts_dir.glob('*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
|
|
2367
2570
|
|
|
2368
2571
|
# Check if hcom files exist
|
|
2369
2572
|
if not log_file.exists() and not instances_dir.exists():
|
|
2370
|
-
print("No
|
|
2573
|
+
print("No HCOM conversation to clear")
|
|
2371
2574
|
return 0
|
|
2372
2575
|
|
|
2373
2576
|
# Archive existing files if they have content
|
|
@@ -2381,7 +2584,7 @@ def cmd_clear():
|
|
|
2381
2584
|
if has_log or has_instances:
|
|
2382
2585
|
# Create session archive folder with timestamp
|
|
2383
2586
|
session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
|
|
2384
|
-
session_archive.mkdir(exist_ok=True)
|
|
2587
|
+
session_archive.mkdir(parents=True, exist_ok=True)
|
|
2385
2588
|
|
|
2386
2589
|
# Archive log file
|
|
2387
2590
|
if has_log:
|
|
@@ -2394,16 +2597,12 @@ def cmd_clear():
|
|
|
2394
2597
|
# Archive instances
|
|
2395
2598
|
if has_instances:
|
|
2396
2599
|
archive_instances = session_archive / INSTANCES_DIR
|
|
2397
|
-
archive_instances.mkdir(exist_ok=True)
|
|
2600
|
+
archive_instances.mkdir(parents=True, exist_ok=True)
|
|
2398
2601
|
|
|
2399
2602
|
# Move json files only
|
|
2400
2603
|
for f in instances_dir.glob('*.json'):
|
|
2401
2604
|
f.rename(archive_instances / f.name)
|
|
2402
2605
|
|
|
2403
|
-
# Clean up orphaned mapping files (position files are archived)
|
|
2404
|
-
for f in instances_dir.glob('.launch_map_*'):
|
|
2405
|
-
f.unlink(missing_ok=True)
|
|
2406
|
-
|
|
2407
2606
|
archived = True
|
|
2408
2607
|
else:
|
|
2409
2608
|
# Clean up empty files/dirs
|
|
@@ -2417,14 +2616,33 @@ def cmd_clear():
|
|
|
2417
2616
|
|
|
2418
2617
|
if archived:
|
|
2419
2618
|
print(f"Archived to archive/session-{timestamp}/")
|
|
2420
|
-
print("Started fresh
|
|
2619
|
+
print("Started fresh HCOM conversation log")
|
|
2421
2620
|
return 0
|
|
2422
2621
|
|
|
2423
2622
|
except Exception as e:
|
|
2424
2623
|
print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
|
|
2425
2624
|
return 1
|
|
2426
2625
|
|
|
2427
|
-
def
|
|
2626
|
+
def remove_global_hooks() -> bool:
|
|
2627
|
+
"""Remove HCOM hooks from ~/.claude/settings.json
|
|
2628
|
+
Returns True on success, False on failure."""
|
|
2629
|
+
settings_path = get_claude_settings_path()
|
|
2630
|
+
|
|
2631
|
+
if not settings_path.exists():
|
|
2632
|
+
return True # No settings = no hooks to remove
|
|
2633
|
+
|
|
2634
|
+
try:
|
|
2635
|
+
settings = load_settings_json(settings_path, default=None)
|
|
2636
|
+
if not settings:
|
|
2637
|
+
return False
|
|
2638
|
+
|
|
2639
|
+
_remove_hcom_hooks_from_settings(settings)
|
|
2640
|
+
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
2641
|
+
return True
|
|
2642
|
+
except Exception:
|
|
2643
|
+
return False
|
|
2644
|
+
|
|
2645
|
+
def cleanup_directory_hooks(directory: Path | str) -> tuple[int, str]:
|
|
2428
2646
|
"""Remove hcom hooks from a specific directory
|
|
2429
2647
|
Returns tuple: (exit_code, message)
|
|
2430
2648
|
exit_code: 0 for success, 1 for error
|
|
@@ -2437,25 +2655,21 @@ def cleanup_directory_hooks(directory):
|
|
|
2437
2655
|
|
|
2438
2656
|
try:
|
|
2439
2657
|
# Load existing settings
|
|
2440
|
-
settings =
|
|
2441
|
-
settings_path,
|
|
2442
|
-
lambda f: json.load(f),
|
|
2443
|
-
default=None
|
|
2444
|
-
)
|
|
2658
|
+
settings = load_settings_json(settings_path, default=None)
|
|
2445
2659
|
if not settings:
|
|
2446
2660
|
return 1, "Cannot read Claude settings"
|
|
2447
2661
|
|
|
2448
2662
|
hooks_found = False
|
|
2449
|
-
|
|
2663
|
+
|
|
2450
2664
|
# Include PostToolUse for backward compatibility cleanup
|
|
2451
2665
|
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2452
|
-
for event in
|
|
2666
|
+
for event in LEGACY_HOOK_TYPES)
|
|
2453
2667
|
|
|
2454
2668
|
_remove_hcom_hooks_from_settings(settings)
|
|
2455
2669
|
|
|
2456
2670
|
# Check if any were removed
|
|
2457
2671
|
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2458
|
-
for event in
|
|
2672
|
+
for event in LEGACY_HOOK_TYPES)
|
|
2459
2673
|
if new_hook_count < original_hook_count:
|
|
2460
2674
|
hooks_found = True
|
|
2461
2675
|
|
|
@@ -2478,59 +2692,289 @@ def cleanup_directory_hooks(directory):
|
|
|
2478
2692
|
return 1, format_error(f"Cannot modify settings.local.json: {e}")
|
|
2479
2693
|
|
|
2480
2694
|
|
|
2481
|
-
def
|
|
2482
|
-
"""
|
|
2695
|
+
def cmd_stop(argv: list[str]) -> int:
|
|
2696
|
+
"""Stop instances: hcom stop [alias|all] [--force] [--_hcom_session ID]"""
|
|
2697
|
+
# Parse arguments
|
|
2698
|
+
target = None
|
|
2699
|
+
force = '--force' in argv
|
|
2700
|
+
session_id = None
|
|
2701
|
+
|
|
2702
|
+
# Extract --_hcom_session if present
|
|
2703
|
+
if '--_hcom_session' in argv:
|
|
2704
|
+
idx = argv.index('--_hcom_session')
|
|
2705
|
+
if idx + 1 < len(argv):
|
|
2706
|
+
session_id = argv[idx + 1]
|
|
2707
|
+
argv = argv[:idx] + argv[idx + 2:]
|
|
2708
|
+
|
|
2709
|
+
# Remove flags to get target
|
|
2710
|
+
args_without_flags = [a for a in argv if not a.startswith('--')]
|
|
2711
|
+
if args_without_flags:
|
|
2712
|
+
target = args_without_flags[0]
|
|
2713
|
+
|
|
2714
|
+
# Handle 'all' target
|
|
2715
|
+
if target == 'all':
|
|
2716
|
+
positions = load_all_positions()
|
|
2717
|
+
|
|
2718
|
+
if not positions:
|
|
2719
|
+
print("No instances found")
|
|
2720
|
+
return 0
|
|
2483
2721
|
|
|
2484
|
-
|
|
2485
|
-
|
|
2722
|
+
stopped_count = 0
|
|
2723
|
+
bg_logs = []
|
|
2724
|
+
stopped_names = []
|
|
2725
|
+
for instance_name, instance_data in positions.items():
|
|
2726
|
+
if instance_data.get('enabled', False):
|
|
2727
|
+
disable_instance(instance_name)
|
|
2728
|
+
stopped_names.append(instance_name)
|
|
2729
|
+
stopped_count += 1
|
|
2730
|
+
|
|
2731
|
+
# Track background logs
|
|
2732
|
+
if instance_data.get('background'):
|
|
2733
|
+
log_file = instance_data.get('background_log_file', '')
|
|
2734
|
+
if log_file:
|
|
2735
|
+
bg_logs.append((instance_name, log_file))
|
|
2486
2736
|
|
|
2487
|
-
|
|
2488
|
-
|
|
2737
|
+
if stopped_count == 0:
|
|
2738
|
+
print("No instances to stop")
|
|
2739
|
+
else:
|
|
2740
|
+
print(f"Stopped {stopped_count} instance(s): {', '.join(stopped_names)}")
|
|
2489
2741
|
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2742
|
+
# Show background logs if any
|
|
2743
|
+
if bg_logs:
|
|
2744
|
+
print()
|
|
2745
|
+
print("Background instance logs:")
|
|
2746
|
+
for name, log_file in bg_logs:
|
|
2747
|
+
print(f" {name}: {log_file}")
|
|
2493
2748
|
|
|
2494
|
-
|
|
2495
|
-
for target_name, target_data in targets:
|
|
2496
|
-
status, age, _ = get_instance_status(target_data)
|
|
2497
|
-
instance_type = "background" if target_data.get('background') else "foreground"
|
|
2749
|
+
return 0
|
|
2498
2750
|
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2751
|
+
# Stop specific instance or self
|
|
2752
|
+
# Get instance name from injected session or target
|
|
2753
|
+
if session_id and not target:
|
|
2754
|
+
instance_name, _ = resolve_instance_name(session_id, get_config().tag)
|
|
2755
|
+
else:
|
|
2756
|
+
instance_name = target
|
|
2757
|
+
|
|
2758
|
+
position = load_instance_position(instance_name) if instance_name else None
|
|
2759
|
+
|
|
2760
|
+
if not instance_name:
|
|
2761
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
2762
|
+
print("Error: Cannot determine instance", file=sys.stderr)
|
|
2763
|
+
print("Usage: Prompt Claude to run 'hcom stop' (or directly use: hcom stop <alias> or hcom stop all)", file=sys.stderr)
|
|
2764
|
+
else:
|
|
2765
|
+
print("Error: Alias required", file=sys.stderr)
|
|
2766
|
+
print("Usage: hcom stop <alias>", file=sys.stderr)
|
|
2767
|
+
print(" Or: hcom stop all", file=sys.stderr)
|
|
2768
|
+
print(" Or: prompt claude to run 'hcom stop' on itself", file=sys.stderr)
|
|
2769
|
+
positions = load_all_positions()
|
|
2770
|
+
visible = [alias for alias, data in positions.items() if should_show_in_watch(data)]
|
|
2771
|
+
if visible:
|
|
2772
|
+
print(f"Active aliases: {', '.join(sorted(visible))}", file=sys.stderr)
|
|
2773
|
+
return 1
|
|
2774
|
+
|
|
2775
|
+
if not position:
|
|
2776
|
+
print(f"No instance found for {instance_name}")
|
|
2777
|
+
return 1
|
|
2778
|
+
|
|
2779
|
+
# Skip already stopped instances (unless forcing)
|
|
2780
|
+
if not position.get('enabled', False) and not force:
|
|
2781
|
+
print(f"HCOM already stopped for {instance_name}")
|
|
2782
|
+
return 0
|
|
2783
|
+
|
|
2784
|
+
# Disable instance (optionally with force)
|
|
2785
|
+
disable_instance(instance_name, force=force)
|
|
2786
|
+
|
|
2787
|
+
if force:
|
|
2788
|
+
print(f"⚠️ Force stopped HCOM for {instance_name}.")
|
|
2789
|
+
print(f" Bash tool use is now DENIED.")
|
|
2790
|
+
print(f" To restart: hcom start {instance_name}")
|
|
2791
|
+
else:
|
|
2792
|
+
print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
|
|
2793
|
+
|
|
2794
|
+
# Show background log location if applicable
|
|
2795
|
+
if position.get('background'):
|
|
2796
|
+
log_file = position.get('background_log_file', '')
|
|
2797
|
+
if log_file:
|
|
2798
|
+
print(f"\nBackground log: {log_file}")
|
|
2799
|
+
print(f"Monitor: tail -f {log_file}")
|
|
2800
|
+
if not force:
|
|
2801
|
+
print(f"Force stop: hcom stop --force {instance_name}")
|
|
2802
|
+
|
|
2803
|
+
return 0
|
|
2804
|
+
|
|
2805
|
+
def cmd_start(argv: list[str]) -> int:
|
|
2806
|
+
"""Enable HCOM participation: hcom start [alias] [--_hcom_session ID]"""
|
|
2807
|
+
# Parse arguments
|
|
2808
|
+
target = None
|
|
2809
|
+
session_id = None
|
|
2810
|
+
|
|
2811
|
+
# Extract --_hcom_session if present
|
|
2812
|
+
if '--_hcom_session' in argv:
|
|
2813
|
+
idx = argv.index('--_hcom_session')
|
|
2814
|
+
if idx + 1 < len(argv):
|
|
2815
|
+
session_id = argv[idx + 1]
|
|
2816
|
+
argv = argv[:idx] + argv[idx + 2:]
|
|
2817
|
+
|
|
2818
|
+
# Remove flags to get target
|
|
2819
|
+
args_without_flags = [a for a in argv if not a.startswith('--')]
|
|
2820
|
+
if args_without_flags:
|
|
2821
|
+
target = args_without_flags[0]
|
|
2822
|
+
|
|
2823
|
+
# Get instance name from injected session or target
|
|
2824
|
+
if session_id and not target:
|
|
2825
|
+
instance_name, existing_data = resolve_instance_name(session_id, get_config().tag)
|
|
2826
|
+
|
|
2827
|
+
# Check if bootstrap needed (before any state changes)
|
|
2828
|
+
needs_bootstrap = not (existing_data and existing_data.get('alias_announced', False))
|
|
2829
|
+
|
|
2830
|
+
# Create instance if it doesn't exist (opt-in for vanilla instances)
|
|
2831
|
+
if not existing_data:
|
|
2832
|
+
initialize_instance_in_position_file(instance_name, session_id)
|
|
2833
|
+
# Enable instance (clears all stop flags)
|
|
2834
|
+
enable_instance(instance_name)
|
|
2835
|
+
|
|
2836
|
+
|
|
2837
|
+
|
|
2838
|
+
print(f"\nStarted HCOM for {instance_name}")
|
|
2839
|
+
|
|
2840
|
+
# Show bootstrap for new instances
|
|
2841
|
+
if needs_bootstrap:
|
|
2842
|
+
print(f"\n\n\n{build_hcom_bootstrap_text(instance_name)}")
|
|
2843
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
2844
|
+
else:
|
|
2845
|
+
# Skip already started instances
|
|
2846
|
+
if existing_data.get('enabled', False):
|
|
2847
|
+
print(f"HCOM already started for {instance_name}")
|
|
2848
|
+
return 0
|
|
2849
|
+
|
|
2850
|
+
# Check if background instance has exited permanently
|
|
2851
|
+
if existing_data.get('session_ended') and existing_data.get('background'):
|
|
2852
|
+
session = existing_data.get('session_id', '')
|
|
2853
|
+
print(f"Cannot start {instance_name}: background instance has exited permanently")
|
|
2854
|
+
print(f"Background instances terminate when stopped and cannot be restarted")
|
|
2855
|
+
if session:
|
|
2856
|
+
print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
|
|
2857
|
+
return 1
|
|
2858
|
+
|
|
2859
|
+
# Re-enabling existing instance
|
|
2860
|
+
enable_instance(instance_name)
|
|
2861
|
+
# First time vs rejoining: check if has read messages (pos > starting_pos)
|
|
2862
|
+
has_participated = existing_data.get('pos', 0) > existing_data.get('starting_pos', 0)
|
|
2863
|
+
if has_participated:
|
|
2864
|
+
print(f"\nStarted HCOM for {instance_name}. Rejoined chat.")
|
|
2510
2865
|
else:
|
|
2511
|
-
|
|
2512
|
-
terminate_process(pid, force=True)
|
|
2513
|
-
time.sleep(0.1)
|
|
2866
|
+
print(f"\nStarted HCOM for {instance_name}. Joined chat.")
|
|
2514
2867
|
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2868
|
+
# Show bootstrap before re-enabling if needed
|
|
2869
|
+
if needs_bootstrap:
|
|
2870
|
+
print(f"\n\n\n{build_hcom_bootstrap_text(instance_name)}")
|
|
2871
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
2519
2872
|
|
|
2520
|
-
|
|
2521
|
-
update_instance_position(target_name, {'pid': None})
|
|
2522
|
-
set_status(target_name, 'killed')
|
|
2873
|
+
return 0
|
|
2523
2874
|
|
|
2524
|
-
|
|
2525
|
-
|
|
2875
|
+
# CLI path: start specific instance
|
|
2876
|
+
positions = load_all_positions()
|
|
2877
|
+
|
|
2878
|
+
# Handle missing target from external CLI
|
|
2879
|
+
if not target:
|
|
2880
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
2881
|
+
print("Error: Cannot determine instance", file=sys.stderr)
|
|
2882
|
+
print("Usage: Prompt Claude to run 'hcom start' (or: hcom start <alias>)", file=sys.stderr)
|
|
2883
|
+
else:
|
|
2884
|
+
print("Error: Alias required", file=sys.stderr)
|
|
2885
|
+
print("Usage: hcom start <alias> (or: prompt claude to run 'hcom start')", file=sys.stderr)
|
|
2886
|
+
print("To launch new instances: hcom <count>", file=sys.stderr)
|
|
2887
|
+
return 1
|
|
2888
|
+
|
|
2889
|
+
# Start specific instance
|
|
2890
|
+
instance_name = target
|
|
2891
|
+
position = positions.get(instance_name)
|
|
2526
2892
|
|
|
2893
|
+
if not position:
|
|
2894
|
+
print(f"Instance not found: {instance_name}")
|
|
2895
|
+
return 1
|
|
2896
|
+
|
|
2897
|
+
# Skip already started instances
|
|
2898
|
+
if position.get('enabled', False):
|
|
2899
|
+
print(f"HCOM already started for {instance_name}")
|
|
2900
|
+
return 0
|
|
2901
|
+
|
|
2902
|
+
# Check if background instance has exited permanently
|
|
2903
|
+
if position.get('session_ended') and position.get('background'):
|
|
2904
|
+
session = position.get('session_id', '')
|
|
2905
|
+
print(f"Cannot start {instance_name}: background instance has exited permanently")
|
|
2906
|
+
print(f"Background instances terminate when stopped and cannot be restarted")
|
|
2907
|
+
if session:
|
|
2908
|
+
print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
|
|
2909
|
+
return 1
|
|
2910
|
+
|
|
2911
|
+
# Enable instance (clears all stop flags)
|
|
2912
|
+
enable_instance(instance_name)
|
|
2913
|
+
|
|
2914
|
+
print(f"Started HCOM for {instance_name}. Rejoined chat.")
|
|
2527
2915
|
return 0
|
|
2528
2916
|
|
|
2529
|
-
def
|
|
2917
|
+
def cmd_reset(argv: list[str]) -> int:
|
|
2918
|
+
"""Reset HCOM components: logs, hooks, config
|
|
2919
|
+
|
|
2920
|
+
Usage:
|
|
2921
|
+
hcom reset # Everything (stop all + logs + hooks + config)
|
|
2922
|
+
hcom reset logs # Archive conversation only
|
|
2923
|
+
hcom reset hooks # Remove hooks only
|
|
2924
|
+
hcom reset config # Clear config (backup to config.env.TIMESTAMP)
|
|
2925
|
+
hcom reset logs hooks # Combine targets
|
|
2926
|
+
"""
|
|
2927
|
+
# No args = everything
|
|
2928
|
+
do_everything = not argv
|
|
2929
|
+
targets = argv if argv else ['logs', 'hooks', 'config']
|
|
2930
|
+
|
|
2931
|
+
# Validate targets
|
|
2932
|
+
valid = {'logs', 'hooks', 'config'}
|
|
2933
|
+
invalid = [t for t in targets if t not in valid]
|
|
2934
|
+
if invalid:
|
|
2935
|
+
print(f"Invalid target(s): {', '.join(invalid)}", file=sys.stderr)
|
|
2936
|
+
print("Valid targets: logs, hooks, config", file=sys.stderr)
|
|
2937
|
+
return 1
|
|
2938
|
+
|
|
2939
|
+
exit_codes = []
|
|
2940
|
+
|
|
2941
|
+
# Stop all instances if doing everything
|
|
2942
|
+
if do_everything:
|
|
2943
|
+
exit_codes.append(cmd_stop(['all']))
|
|
2944
|
+
|
|
2945
|
+
# Execute based on targets
|
|
2946
|
+
if 'logs' in targets:
|
|
2947
|
+
exit_codes.append(clear())
|
|
2948
|
+
|
|
2949
|
+
if 'hooks' in targets:
|
|
2950
|
+
exit_codes.append(cleanup('--all'))
|
|
2951
|
+
if remove_global_hooks():
|
|
2952
|
+
print("Removed hooks")
|
|
2953
|
+
else:
|
|
2954
|
+
print("Warning: Could not remove hooks. Check your claude settings.json file it might be invalid", file=sys.stderr)
|
|
2955
|
+
exit_codes.append(1)
|
|
2956
|
+
|
|
2957
|
+
if 'config' in targets:
|
|
2958
|
+
config_path = hcom_path(CONFIG_FILE)
|
|
2959
|
+
if config_path.exists():
|
|
2960
|
+
# Backup with timestamp
|
|
2961
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
2962
|
+
backup_path = hcom_path(f'config.env.{timestamp}')
|
|
2963
|
+
shutil.copy2(config_path, backup_path)
|
|
2964
|
+
config_path.unlink()
|
|
2965
|
+
print(f"Config backed up to config.env.{timestamp} and cleared")
|
|
2966
|
+
exit_codes.append(0)
|
|
2967
|
+
else:
|
|
2968
|
+
print("No config file to clear")
|
|
2969
|
+
exit_codes.append(0)
|
|
2970
|
+
|
|
2971
|
+
return max(exit_codes) if exit_codes else 0
|
|
2972
|
+
|
|
2973
|
+
def cleanup(*args: str) -> int:
|
|
2530
2974
|
"""Remove hcom hooks from current directory or all directories"""
|
|
2531
2975
|
if args and args[0] == '--all':
|
|
2532
2976
|
directories = set()
|
|
2533
|
-
|
|
2977
|
+
|
|
2534
2978
|
# Get all directories from current instances
|
|
2535
2979
|
try:
|
|
2536
2980
|
positions = load_all_positions()
|
|
@@ -2540,9 +2984,27 @@ def cmd_cleanup(*args):
|
|
|
2540
2984
|
directories.add(instance_data['directory'])
|
|
2541
2985
|
except Exception as e:
|
|
2542
2986
|
print(f"Warning: Could not read current instances: {e}")
|
|
2987
|
+
|
|
2988
|
+
# Also check archived instances for directories (until 0.5.0)
|
|
2989
|
+
try:
|
|
2990
|
+
archive_dir = hcom_path(ARCHIVE_DIR)
|
|
2991
|
+
if archive_dir.exists():
|
|
2992
|
+
for session_dir in archive_dir.iterdir():
|
|
2993
|
+
if session_dir.is_dir() and session_dir.name.startswith('session-'):
|
|
2994
|
+
instances_dir = session_dir / 'instances'
|
|
2995
|
+
if instances_dir.exists():
|
|
2996
|
+
for instance_file in instances_dir.glob('*.json'):
|
|
2997
|
+
try:
|
|
2998
|
+
data = json.loads(instance_file.read_text())
|
|
2999
|
+
if 'directory' in data:
|
|
3000
|
+
directories.add(data['directory'])
|
|
3001
|
+
except Exception:
|
|
3002
|
+
pass
|
|
3003
|
+
except Exception as e:
|
|
3004
|
+
print(f"Warning: Could not read archived instances: {e}")
|
|
2543
3005
|
|
|
2544
3006
|
if not directories:
|
|
2545
|
-
print("No directories found in current
|
|
3007
|
+
print("No directories found in current HCOM tracking")
|
|
2546
3008
|
return 0
|
|
2547
3009
|
|
|
2548
3010
|
print(f"Found {len(directories)} unique directories to check")
|
|
@@ -2583,8 +3045,110 @@ def cmd_cleanup(*args):
|
|
|
2583
3045
|
print(message)
|
|
2584
3046
|
return exit_code
|
|
2585
3047
|
|
|
2586
|
-
def
|
|
2587
|
-
"""
|
|
3048
|
+
def is_plugin_active() -> bool:
|
|
3049
|
+
"""Check if hcom plugin is enabled in Claude Code settings."""
|
|
3050
|
+
settings_path = get_claude_settings_path()
|
|
3051
|
+
if not settings_path.exists():
|
|
3052
|
+
return False
|
|
3053
|
+
|
|
3054
|
+
try:
|
|
3055
|
+
settings = load_settings_json(settings_path, default={})
|
|
3056
|
+
return settings.get('enabledPlugins', {}).get('hcom@hcom', False)
|
|
3057
|
+
except Exception:
|
|
3058
|
+
return False
|
|
3059
|
+
|
|
3060
|
+
def has_direct_hooks_present() -> bool:
|
|
3061
|
+
"""Check if direct HCOM hooks exist in settings.json
|
|
3062
|
+
Direct hooks always set env.HCOM, plugin hooks don't touch settings.json.
|
|
3063
|
+
"""
|
|
3064
|
+
settings_path = get_claude_settings_path()
|
|
3065
|
+
if not settings_path.exists():
|
|
3066
|
+
return False
|
|
3067
|
+
try:
|
|
3068
|
+
settings = load_settings_json(settings_path, default=None)
|
|
3069
|
+
# Direct hooks marker: HCOM environment variable
|
|
3070
|
+
return bool(settings and 'HCOM' in settings.get('env', {}))
|
|
3071
|
+
except Exception:
|
|
3072
|
+
return False
|
|
3073
|
+
|
|
3074
|
+
def ensure_hooks_current() -> bool:
|
|
3075
|
+
"""Ensure hooks match current execution context - called on EVERY command.
|
|
3076
|
+
Manages transition between plugin and direct hooks automatically.
|
|
3077
|
+
Auto-updates hooks if execution context changes (e.g., pip → uvx).
|
|
3078
|
+
Always returns True (warns but never blocks - Claude Code is fault-tolerant)."""
|
|
3079
|
+
|
|
3080
|
+
# Plugin manages hooks?
|
|
3081
|
+
if is_plugin_active():
|
|
3082
|
+
# Clean up any stale direct hooks (plugin/direct transition)
|
|
3083
|
+
if has_direct_hooks_present():
|
|
3084
|
+
print("Plugin detected. Cleaning up direct hooks...", file=sys.stderr)
|
|
3085
|
+
if remove_global_hooks():
|
|
3086
|
+
print("✓ Using plugin hooks exclusively.", file=sys.stderr)
|
|
3087
|
+
# Only ask for restart if inside Claude Code
|
|
3088
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
3089
|
+
print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
|
|
3090
|
+
print("=" * 60, file=sys.stderr)
|
|
3091
|
+
else:
|
|
3092
|
+
# Failed to remove - warn but continue (plugin hooks still work)
|
|
3093
|
+
print("⚠️ Could not remove direct hooks. Check ~/.claude/settings.json", file=sys.stderr)
|
|
3094
|
+
return True # Plugin hooks active, all good
|
|
3095
|
+
|
|
3096
|
+
# Direct hooks: verify they exist and match current execution context
|
|
3097
|
+
global_settings = get_claude_settings_path()
|
|
3098
|
+
|
|
3099
|
+
# Check if hooks are valid (exist + env var matches current context)
|
|
3100
|
+
hooks_exist = verify_hooks_installed(global_settings)
|
|
3101
|
+
env_var_matches = False
|
|
3102
|
+
|
|
3103
|
+
if hooks_exist:
|
|
3104
|
+
try:
|
|
3105
|
+
settings = load_settings_json(global_settings, default={})
|
|
3106
|
+
if settings is None:
|
|
3107
|
+
settings = {}
|
|
3108
|
+
current_hcom = _build_hcom_env_value()
|
|
3109
|
+
installed_hcom = settings.get('env', {}).get('HCOM')
|
|
3110
|
+
env_var_matches = (installed_hcom == current_hcom)
|
|
3111
|
+
except Exception:
|
|
3112
|
+
# Failed to read settings - try to fix by updating
|
|
3113
|
+
env_var_matches = False
|
|
3114
|
+
|
|
3115
|
+
# Install/update hooks if missing or env var wrong
|
|
3116
|
+
if not hooks_exist or not env_var_matches:
|
|
3117
|
+
try:
|
|
3118
|
+
setup_hooks()
|
|
3119
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
3120
|
+
print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
|
|
3121
|
+
print("=" * 60, file=sys.stderr)
|
|
3122
|
+
except Exception as e:
|
|
3123
|
+
# Failed to verify/update hooks, but they might still work
|
|
3124
|
+
# Claude Code is fault-tolerant with malformed JSON
|
|
3125
|
+
print(f"⚠️ Could not verify/update hooks: {e}", file=sys.stderr)
|
|
3126
|
+
print("If HCOM doesn't work, check ~/.claude/settings.json", file=sys.stderr)
|
|
3127
|
+
|
|
3128
|
+
return True
|
|
3129
|
+
|
|
3130
|
+
def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> int:
|
|
3131
|
+
"""Send message to hcom: hcom send "message" [--_hcom_session ID]"""
|
|
3132
|
+
# Parse message and session_id
|
|
3133
|
+
message = None
|
|
3134
|
+
session_id = None
|
|
3135
|
+
|
|
3136
|
+
# Extract --_hcom_session if present (injected by PreToolUse hook)
|
|
3137
|
+
if '--_hcom_session' in argv:
|
|
3138
|
+
idx = argv.index('--_hcom_session')
|
|
3139
|
+
if idx + 1 < len(argv):
|
|
3140
|
+
session_id = argv[idx + 1]
|
|
3141
|
+
argv = argv[:idx] + argv[idx + 2:] # Remove flag and value
|
|
3142
|
+
|
|
3143
|
+
# First non-flag argument is the message
|
|
3144
|
+
if argv:
|
|
3145
|
+
message = argv[0]
|
|
3146
|
+
|
|
3147
|
+
# Check message is provided
|
|
3148
|
+
if not message:
|
|
3149
|
+
print(format_error("No message provided"), file=sys.stderr)
|
|
3150
|
+
return 1
|
|
3151
|
+
|
|
2588
3152
|
# Check if hcom files exist
|
|
2589
3153
|
log_file = hcom_path(LOG_FILE)
|
|
2590
3154
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
@@ -2605,7 +3169,7 @@ def cmd_send(message):
|
|
|
2605
3169
|
try:
|
|
2606
3170
|
positions = load_all_positions()
|
|
2607
3171
|
all_instances = list(positions.keys())
|
|
2608
|
-
sender_name =
|
|
3172
|
+
sender_name = SENDER
|
|
2609
3173
|
all_names = all_instances + [sender_name]
|
|
2610
3174
|
unmatched = [m for m in mentions
|
|
2611
3175
|
if not any(name.lower().startswith(m.lower()) for name in all_names)]
|
|
@@ -2614,90 +3178,72 @@ def cmd_send(message):
|
|
|
2614
3178
|
except Exception:
|
|
2615
3179
|
pass # Don't fail on warning
|
|
2616
3180
|
|
|
2617
|
-
# Determine sender
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
if launch_id:
|
|
3181
|
+
# Determine sender from injected session_id or CLI
|
|
3182
|
+
if session_id and not force_cli:
|
|
3183
|
+
# Instance context - resolve name from session_id (searches existing instances first)
|
|
2621
3184
|
try:
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
pass
|
|
3185
|
+
sender_name, instance_data = resolve_instance_name(session_id, get_config().tag)
|
|
3186
|
+
except (ValueError, Exception) as e:
|
|
3187
|
+
print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
|
|
3188
|
+
return 1
|
|
2627
3189
|
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
3190
|
+
# Initialize instance if doesn't exist (first use)
|
|
3191
|
+
if not instance_data:
|
|
3192
|
+
initialize_instance_in_position_file(sender_name, session_id)
|
|
3193
|
+
instance_data = load_instance_position(sender_name)
|
|
3194
|
+
|
|
3195
|
+
# Check force_closed
|
|
3196
|
+
if instance_data.get('force_closed'):
|
|
3197
|
+
print(format_error(f"HCOM force stopped for this instance. To recover, delete instance file: rm ~/.hcom/instances/{sender_name}.json"), file=sys.stderr)
|
|
3198
|
+
return 1
|
|
3199
|
+
|
|
3200
|
+
# Check enabled state
|
|
3201
|
+
if not instance_data.get('enabled', False):
|
|
3202
|
+
print(format_error("HCOM not started for this instance. To send a message first run: 'hcom start' then use hcom send"), file=sys.stderr)
|
|
3203
|
+
return 1
|
|
3204
|
+
|
|
3205
|
+
# Send message
|
|
3206
|
+
if not send_message(sender_name, message):
|
|
3207
|
+
print(format_error("Failed to send message"), file=sys.stderr)
|
|
3208
|
+
return 1
|
|
3209
|
+
|
|
3210
|
+
# Show unread messages
|
|
3211
|
+
messages = get_unread_messages(sender_name, update_position=True)
|
|
3212
|
+
if messages:
|
|
3213
|
+
max_msgs = MAX_MESSAGES_PER_DELIVERY
|
|
3214
|
+
formatted = format_hook_messages(messages[:max_msgs], sender_name)
|
|
3215
|
+
print(f"Message sent\n\n{formatted}", file=sys.stderr)
|
|
2642
3216
|
else:
|
|
2643
|
-
# Bigboss: just confirm send
|
|
2644
3217
|
print("Message sent", file=sys.stderr)
|
|
2645
|
-
|
|
2646
|
-
# Show cli_hints if configured (non-interactive mode)
|
|
2647
|
-
if not is_interactive():
|
|
2648
|
-
show_cli_hints()
|
|
2649
|
-
|
|
3218
|
+
|
|
2650
3219
|
return 0
|
|
2651
3220
|
else:
|
|
2652
|
-
|
|
2653
|
-
return 1
|
|
3221
|
+
# CLI context - no session_id or force_cli=True
|
|
2654
3222
|
|
|
2655
|
-
|
|
2656
|
-
|
|
3223
|
+
# Warn if inside Claude Code but no session_id (hooks not working)
|
|
3224
|
+
if os.environ.get('CLAUDECODE') == '1' and not session_id and not force_cli:
|
|
3225
|
+
print(f"⚠️ Cannot determine alias - message sent as '{SENDER}'", file=sys.stderr)
|
|
3226
|
+
print(" Prompt Claude to send a hcom message instead of using bash mode (! prefix).", file=sys.stderr)
|
|
2657
3227
|
|
|
2658
|
-
INTERNAL COMMAND: Only called via 'eval $HCOM send --resume alias' during implicit resume workflow.
|
|
2659
|
-
Not meant for direct CLI usage.
|
|
2660
|
-
"""
|
|
2661
|
-
# Get current instance name via launch_id mapping (same mechanism as cmd_send)
|
|
2662
|
-
# The mapping is created by init_hook_context() when hooks run
|
|
2663
|
-
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2664
|
-
if not launch_id:
|
|
2665
|
-
print(format_error("Not in HCOM instance context - no launch ID"), file=sys.stderr)
|
|
2666
|
-
return 1
|
|
2667
3228
|
|
|
2668
|
-
|
|
2669
|
-
try:
|
|
2670
|
-
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
|
|
2671
|
-
if mapping_file.exists():
|
|
2672
|
-
instance_name = mapping_file.read_text(encoding='utf-8').strip()
|
|
2673
|
-
except Exception:
|
|
2674
|
-
pass
|
|
3229
|
+
sender_name = SENDER
|
|
2675
3230
|
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
# Sanitize alias: only allow alphanumeric, dash, underscore
|
|
2681
|
-
# This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
|
|
2682
|
-
if not re.match(r'^[A-Za-z0-9\-_]+$', alias):
|
|
2683
|
-
print(format_error("Invalid alias format. Use alphanumeric, dash, or underscore only"), file=sys.stderr)
|
|
2684
|
-
return 1
|
|
3231
|
+
if not send_message(sender_name, message):
|
|
3232
|
+
print(format_error("Failed to send message"), file=sys.stderr)
|
|
3233
|
+
return 1
|
|
2685
3234
|
|
|
2686
|
-
|
|
2687
|
-
|
|
3235
|
+
if not quiet:
|
|
3236
|
+
print(f"✓ Sent from {sender_name}", file=sys.stderr)
|
|
2688
3237
|
|
|
2689
|
-
|
|
2690
|
-
if not status:
|
|
2691
|
-
# Empty status means names matched (from_name == to_name)
|
|
2692
|
-
status = f"[SUCCESS] ✓ Already using alias {alias}"
|
|
3238
|
+
return 0
|
|
2693
3239
|
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
return
|
|
3240
|
+
def send_cli(message: str, quiet: bool = False) -> int:
|
|
3241
|
+
"""Force CLI sender (skip outbox, use config sender name)"""
|
|
3242
|
+
return cmd_send([message], force_cli=True, quiet=quiet)
|
|
2697
3243
|
|
|
2698
3244
|
# ==================== Hook Helpers ====================
|
|
2699
3245
|
|
|
2700
|
-
def format_hook_messages(messages, instance_name):
|
|
3246
|
+
def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
|
|
2701
3247
|
"""Format messages for hook feedback"""
|
|
2702
3248
|
if len(messages) == 1:
|
|
2703
3249
|
msg = messages[0]
|
|
@@ -2706,147 +3252,117 @@ def format_hook_messages(messages, instance_name):
|
|
|
2706
3252
|
parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
|
|
2707
3253
|
reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
|
|
2708
3254
|
|
|
2709
|
-
# Only append
|
|
2710
|
-
|
|
2711
|
-
if
|
|
2712
|
-
reason = f"{reason} | [{
|
|
3255
|
+
# Only append hints to messages
|
|
3256
|
+
hints = get_config().hints
|
|
3257
|
+
if hints:
|
|
3258
|
+
reason = f"{reason} | [{hints}]"
|
|
2713
3259
|
|
|
2714
3260
|
return reason
|
|
2715
3261
|
|
|
2716
3262
|
# ==================== Hook Handlers ====================
|
|
2717
3263
|
|
|
2718
|
-
def init_hook_context(hook_data, hook_type=None):
|
|
2719
|
-
"""
|
|
3264
|
+
def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -> tuple[str, dict[str, Any], bool]:
|
|
3265
|
+
"""
|
|
3266
|
+
Initialize instance context. Flow:
|
|
3267
|
+
1. Resolve instance name (search by session_id, generate if not found)
|
|
3268
|
+
2. Create instance file if fresh start in UserPromptSubmit
|
|
3269
|
+
3. Build updates dict
|
|
3270
|
+
4. Return (instance_name, updates, is_matched_resume)
|
|
3271
|
+
"""
|
|
2720
3272
|
session_id = hook_data.get('session_id', '')
|
|
2721
3273
|
transcript_path = hook_data.get('transcript_path', '')
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2725
|
-
instance_name = None
|
|
2726
|
-
merged_state = None
|
|
2727
|
-
|
|
2728
|
-
# Check if current session_id matches any existing instance
|
|
2729
|
-
# This maintains identity after resume/merge operations
|
|
2730
|
-
if not instance_name and session_id and instances_dir.exists():
|
|
2731
|
-
for instance_file in instances_dir.glob("*.json"):
|
|
2732
|
-
try:
|
|
2733
|
-
data = load_instance_position(instance_file.stem)
|
|
2734
|
-
if session_id == data.get('session_id'):
|
|
2735
|
-
instance_name = instance_file.stem
|
|
2736
|
-
merged_state = data
|
|
2737
|
-
log_hook_error(f'DEBUG: Session_id {session_id[:8]} matched {instance_file.stem}, reusing that name')
|
|
2738
|
-
break
|
|
2739
|
-
except (json.JSONDecodeError, OSError, KeyError):
|
|
2740
|
-
continue
|
|
3274
|
+
tag = get_config().tag
|
|
2741
3275
|
|
|
2742
|
-
#
|
|
2743
|
-
|
|
2744
|
-
instance_name = get_display_name(session_id, prefix)
|
|
2745
|
-
# DEBUG: Log name generation
|
|
2746
|
-
log_hook_error(f'DEBUG: Generated instance_name={instance_name} from session_id={session_id[:8] if session_id else "None"}')
|
|
2747
|
-
|
|
2748
|
-
# Save launch_id → instance_name mapping for cmd_send()
|
|
2749
|
-
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2750
|
-
if launch_id:
|
|
2751
|
-
try:
|
|
2752
|
-
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}', ensure_parent=True)
|
|
2753
|
-
mapping_file.write_text(instance_name, encoding='utf-8')
|
|
2754
|
-
log_hook_error(f'DEBUG: FINAL - Wrote launch_map_{launch_id} → {instance_name} (session_id={session_id[:8] if session_id else "None"})')
|
|
2755
|
-
except Exception:
|
|
2756
|
-
pass # Non-critical
|
|
3276
|
+
# Resolve instance name - existing_data is None for fresh starts
|
|
3277
|
+
instance_name, existing_data = resolve_instance_name(session_id, tag)
|
|
2757
3278
|
|
|
2758
3279
|
# Save migrated data if we have it
|
|
2759
|
-
if
|
|
2760
|
-
save_instance_position(instance_name,
|
|
2761
|
-
|
|
2762
|
-
# Check if instance is brand new or pre-existing (before creation (WWJD))
|
|
2763
|
-
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
2764
|
-
is_new_instance = not instance_file.exists()
|
|
3280
|
+
if existing_data:
|
|
3281
|
+
save_instance_position(instance_name, existing_data)
|
|
2765
3282
|
|
|
2766
|
-
#
|
|
2767
|
-
|
|
2768
|
-
should_create_instance = not (
|
|
2769
|
-
hook_type == 'sessionstart' and
|
|
2770
|
-
hook_data.get('source', 'startup') == 'resume' and not merged_state
|
|
2771
|
-
)
|
|
2772
|
-
if should_create_instance:
|
|
3283
|
+
# Create instance file if fresh start in UserPromptSubmit
|
|
3284
|
+
if existing_data is None and hook_type == 'userpromptsubmit':
|
|
2773
3285
|
initialize_instance_in_position_file(instance_name, session_id)
|
|
2774
3286
|
|
|
2775
|
-
#
|
|
3287
|
+
# Build updates dict
|
|
2776
3288
|
updates: dict[str, Any] = {
|
|
2777
3289
|
'directory': str(Path.cwd()),
|
|
3290
|
+
'tag': tag,
|
|
2778
3291
|
}
|
|
2779
3292
|
|
|
2780
|
-
# Update session_id (overwrites previous)
|
|
2781
3293
|
if session_id:
|
|
2782
3294
|
updates['session_id'] = session_id
|
|
2783
3295
|
|
|
2784
|
-
# Update transcript_path to current
|
|
2785
3296
|
if transcript_path:
|
|
2786
3297
|
updates['transcript_path'] = transcript_path
|
|
2787
3298
|
|
|
2788
|
-
# Always update PID to current (fixes stale PID on implicit resume)
|
|
2789
|
-
updates['pid'] = os.getppid()
|
|
2790
|
-
|
|
2791
|
-
# Add background status if applicable
|
|
2792
3299
|
bg_env = os.environ.get('HCOM_BACKGROUND')
|
|
2793
3300
|
if bg_env:
|
|
2794
3301
|
updates['background'] = True
|
|
2795
3302
|
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
2796
3303
|
|
|
2797
|
-
#
|
|
2798
|
-
|
|
2799
|
-
|
|
3304
|
+
# Simple boolean: matched resume if existing_data found
|
|
3305
|
+
is_matched_resume = (existing_data is not None)
|
|
3306
|
+
|
|
3307
|
+
return instance_name, updates, is_matched_resume
|
|
3308
|
+
|
|
3309
|
+
def pretooluse_decision(decision: str, reason: str) -> None:
|
|
3310
|
+
"""Exit PreToolUse hook with permission decision"""
|
|
3311
|
+
output = {
|
|
3312
|
+
"hookSpecificOutput": {
|
|
3313
|
+
"hookEventName": "PreToolUse",
|
|
3314
|
+
"permissionDecision": decision,
|
|
3315
|
+
"permissionDecisionReason": reason
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
3319
|
+
sys.exit(EXIT_SUCCESS)
|
|
2800
3320
|
|
|
2801
|
-
def handle_pretooluse(hook_data,
|
|
2802
|
-
"""Handle PreToolUse hook -
|
|
3321
|
+
def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
3322
|
+
"""Handle PreToolUse hook - check force_closed, inject session_id"""
|
|
3323
|
+
instance_data = load_instance_position(instance_name)
|
|
2803
3324
|
tool_name = hook_data.get('tool_name', '')
|
|
3325
|
+
session_id = hook_data.get('session_id', '')
|
|
3326
|
+
|
|
3327
|
+
# FORCE CLOSE CHECK - deny ALL tools
|
|
3328
|
+
if instance_data.get('force_closed'):
|
|
3329
|
+
pretooluse_decision("deny", f"FORCE CLOSED: The user has force stopped HCOM for this instance (you, {instance_name}). Stop using all tools immediately and wait for user instructions.")
|
|
2804
3330
|
|
|
2805
|
-
#
|
|
2806
|
-
|
|
3331
|
+
# Record status for tool execution tracking (only if enabled)
|
|
3332
|
+
if instance_data.get('enabled', False):
|
|
3333
|
+
set_status(instance_name, 'tool_pending', tool_name)
|
|
2807
3334
|
|
|
2808
|
-
#
|
|
2809
|
-
if tool_name == 'Bash':
|
|
3335
|
+
# Inject session_id into hcom commands via updatedInput
|
|
3336
|
+
if tool_name == 'Bash' and session_id:
|
|
2810
3337
|
command = hook_data.get('tool_input', {}).get('command', '')
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
(script_path in command and ' send ' in command)):
|
|
3338
|
+
|
|
3339
|
+
# Match hcom commands for session_id injection and auto-approval
|
|
3340
|
+
matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
|
|
3341
|
+
if matches:
|
|
3342
|
+
# Inject all if chained (&&, ||, ;, |), otherwise first only (avoids quoted text in messages)
|
|
3343
|
+
inject_all = len(matches) > 1 and any(op in command[matches[0].end():matches[1].start()] for op in ['&&', '||', ';', '|'])
|
|
3344
|
+
modified_command = HCOM_COMMAND_PATTERN.sub(rf'\g<0> --_hcom_session {session_id}', command, count=0 if inject_all else 1)
|
|
3345
|
+
|
|
2820
3346
|
output = {
|
|
2821
3347
|
"hookSpecificOutput": {
|
|
2822
3348
|
"hookEventName": "PreToolUse",
|
|
2823
3349
|
"permissionDecision": "allow",
|
|
2824
|
-
"
|
|
3350
|
+
"updatedInput": {
|
|
3351
|
+
"command": modified_command
|
|
3352
|
+
}
|
|
2825
3353
|
}
|
|
2826
3354
|
}
|
|
2827
3355
|
print(json.dumps(output, ensure_ascii=False))
|
|
2828
3356
|
sys.exit(EXIT_SUCCESS)
|
|
2829
3357
|
|
|
2830
3358
|
|
|
2831
|
-
def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
|
|
2832
|
-
"""Safely exit stop hook with proper status tracking"""
|
|
2833
|
-
try:
|
|
2834
|
-
set_status(instance_name, 'stop_exit')
|
|
2835
|
-
except (OSError, PermissionError):
|
|
2836
|
-
pass # Silently handle any errors
|
|
2837
|
-
sys.exit(code)
|
|
2838
3359
|
|
|
2839
|
-
def handle_stop(hook_data, instance_name, updates):
|
|
3360
|
+
def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
2840
3361
|
"""Handle Stop hook - poll for messages and deliver"""
|
|
2841
|
-
parent_pid = os.getppid()
|
|
2842
|
-
log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
|
|
2843
|
-
log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
|
|
2844
|
-
|
|
2845
3362
|
|
|
2846
3363
|
try:
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
timeout = get_config_value('wait_timeout', 1800)
|
|
3364
|
+
updates['last_stop'] = time.time()
|
|
3365
|
+
timeout = get_config().timeout
|
|
2850
3366
|
updates['wait_timeout'] = timeout
|
|
2851
3367
|
set_status(instance_name, 'waiting')
|
|
2852
3368
|
|
|
@@ -2856,45 +3372,63 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
2856
3372
|
log_hook_error(f'stop:update_instance_position({instance_name})', e)
|
|
2857
3373
|
|
|
2858
3374
|
start_time = time.time()
|
|
2859
|
-
log_hook_error(f'stop:start_time_pid_{os.getpid()}')
|
|
2860
3375
|
|
|
2861
3376
|
try:
|
|
2862
|
-
|
|
3377
|
+
first_poll = True
|
|
2863
3378
|
last_heartbeat = start_time
|
|
2864
|
-
#
|
|
3379
|
+
# Actual polling loop - this IS the holding pattern
|
|
2865
3380
|
while time.time() - start_time < timeout:
|
|
2866
|
-
if
|
|
2867
|
-
|
|
2868
|
-
loop_count += 1
|
|
3381
|
+
if first_poll:
|
|
3382
|
+
first_poll = False
|
|
2869
3383
|
|
|
2870
|
-
#
|
|
2871
|
-
if not is_parent_alive(parent_pid):
|
|
2872
|
-
log_hook_error(f'stop:parent_not_alive_pid_{os.getpid()}')
|
|
2873
|
-
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2874
|
-
|
|
2875
|
-
# Load instance data once per poll (needed for messages and user input check)
|
|
3384
|
+
# Reload instance data each poll iteration
|
|
2876
3385
|
instance_data = load_instance_position(instance_name)
|
|
2877
3386
|
|
|
2878
|
-
# Check
|
|
3387
|
+
# Check flag file FIRST (highest priority coordination signal)
|
|
3388
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
3389
|
+
if flag_file.exists():
|
|
3390
|
+
try:
|
|
3391
|
+
flag_file.unlink()
|
|
3392
|
+
except (FileNotFoundError, PermissionError):
|
|
3393
|
+
# Already deleted or locked, continue anyway
|
|
3394
|
+
pass
|
|
3395
|
+
sys.exit(EXIT_SUCCESS)
|
|
3396
|
+
|
|
3397
|
+
# Check if session ended (SessionEnd hook fired) - exit without changing status
|
|
3398
|
+
if instance_data.get('session_ended'):
|
|
3399
|
+
sys.exit(EXIT_SUCCESS) # Don't overwrite session_ended status
|
|
3400
|
+
|
|
3401
|
+
# Check if user input is pending (timestamp fallback) - exit cleanly if recent input
|
|
2879
3402
|
last_user_input = instance_data.get('last_user_input', 0)
|
|
2880
3403
|
if time.time() - last_user_input < 0.2:
|
|
2881
|
-
|
|
2882
|
-
|
|
3404
|
+
sys.exit(EXIT_SUCCESS) # Don't overwrite status - let current status remain
|
|
3405
|
+
|
|
3406
|
+
# Check if stopped/disabled - exit cleanly
|
|
3407
|
+
if not instance_data.get('enabled', False):
|
|
3408
|
+
sys.exit(EXIT_SUCCESS) # Preserve 'stopped' status set by cmd_stop
|
|
2883
3409
|
|
|
2884
3410
|
# Check for new messages and deliver
|
|
2885
3411
|
if messages := get_unread_messages(instance_name, update_position=True):
|
|
2886
|
-
messages_to_show = messages[:
|
|
3412
|
+
messages_to_show = messages[:MAX_MESSAGES_PER_DELIVERY]
|
|
2887
3413
|
reason = format_hook_messages(messages_to_show, instance_name)
|
|
2888
3414
|
set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
|
|
2889
3415
|
|
|
2890
|
-
log_hook_error(f'stop:delivering_message_pid_{os.getpid()}')
|
|
2891
3416
|
output = {"decision": "block", "reason": reason}
|
|
2892
|
-
|
|
2893
|
-
|
|
3417
|
+
output_json = json.dumps(output, ensure_ascii=False)
|
|
3418
|
+
|
|
3419
|
+
# Log what we're about to output for debugging
|
|
3420
|
+
log_hook_error(f'stop:delivering_message|output_len={len(output_json)}', None)
|
|
3421
|
+
log_hook_error(f'stop:output_json|{output_json}', None)
|
|
2894
3422
|
|
|
2895
|
-
|
|
3423
|
+
# Use JSON output method: stdout + exit 0 (per Claude Code hooks reference)
|
|
3424
|
+
# The "decision": "block" field prevents stoppage, allowing next poll cycle
|
|
3425
|
+
print(output_json)
|
|
3426
|
+
sys.stdout.flush()
|
|
3427
|
+
sys.exit(EXIT_SUCCESS)
|
|
3428
|
+
|
|
3429
|
+
# Update heartbeat every 0.5 seconds for staleness detection
|
|
2896
3430
|
now = time.time()
|
|
2897
|
-
if now - last_heartbeat >= 5
|
|
3431
|
+
if now - last_heartbeat >= 0.5:
|
|
2898
3432
|
try:
|
|
2899
3433
|
update_instance_position(instance_name, {'last_stop': now})
|
|
2900
3434
|
last_heartbeat = now
|
|
@@ -2913,183 +3447,298 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
2913
3447
|
except Exception as e:
|
|
2914
3448
|
# Log error and exit gracefully
|
|
2915
3449
|
log_hook_error('handle_stop', e)
|
|
2916
|
-
|
|
3450
|
+
sys.exit(EXIT_SUCCESS) # Preserve previous status on exception
|
|
2917
3451
|
|
|
2918
|
-
def handle_notify(hook_data, instance_name, updates):
|
|
3452
|
+
def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
2919
3453
|
"""Handle Notification hook - track permission requests"""
|
|
2920
3454
|
updates['notification_message'] = hook_data.get('message', '')
|
|
2921
3455
|
update_instance_position(instance_name, updates)
|
|
2922
3456
|
set_status(instance_name, 'blocked', hook_data.get('message', ''))
|
|
2923
3457
|
|
|
2924
|
-
def
|
|
3458
|
+
def get_user_input_flag_file(instance_name: str) -> Path:
|
|
3459
|
+
"""Get path to user input coordination flag file"""
|
|
3460
|
+
return hcom_path(INSTANCES_DIR, f'{instance_name}.user_input')
|
|
3461
|
+
|
|
3462
|
+
def wait_for_stop_exit(instance_name: str, max_wait: float = 0.2) -> int:
|
|
3463
|
+
"""
|
|
3464
|
+
Wait for Stop hook to exit using flag file coordination.
|
|
3465
|
+
Returns wait time in ms.
|
|
3466
|
+
|
|
3467
|
+
Strategy:
|
|
3468
|
+
1. Create flag file
|
|
3469
|
+
2. Wait for Stop hook to delete it (proof it exited)
|
|
3470
|
+
3. Fallback to timeout if Stop hook doesn't delete flag
|
|
3471
|
+
"""
|
|
3472
|
+
start = time.time()
|
|
3473
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
3474
|
+
|
|
3475
|
+
# Wait for flag file to be deleted by Stop hook
|
|
3476
|
+
while flag_file.exists() and time.time() - start < max_wait:
|
|
3477
|
+
time.sleep(0.01)
|
|
3478
|
+
|
|
3479
|
+
return int((time.time() - start) * 1000)
|
|
3480
|
+
|
|
3481
|
+
def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], is_matched_resume: bool, instance_data: dict[str, Any] | None) -> None:
|
|
2925
3482
|
"""Handle UserPromptSubmit hook - track when user sends messages"""
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
3483
|
+
is_enabled = instance_data.get('enabled', False) if instance_data else False
|
|
3484
|
+
last_stop = instance_data.get('last_stop', 0) if instance_data else 0
|
|
3485
|
+
alias_announced = instance_data.get('alias_announced', False) if instance_data else False
|
|
2929
3486
|
|
|
2930
|
-
#
|
|
2931
|
-
|
|
3487
|
+
# Session_ended prevents user recieving messages(?) so reset it.
|
|
3488
|
+
if is_matched_resume and instance_data and instance_data.get('session_ended'):
|
|
3489
|
+
update_instance_position(instance_name, {'session_ended': False})
|
|
3490
|
+
instance_data['session_ended'] = False # Resume path reactivates Stop hook polling
|
|
3491
|
+
|
|
3492
|
+
# Coordinate with Stop hook only if enabled AND Stop hook is active
|
|
3493
|
+
stop_is_active = (time.time() - last_stop) < 1.0
|
|
3494
|
+
|
|
3495
|
+
if is_enabled and stop_is_active:
|
|
3496
|
+
# Create flag file for coordination
|
|
3497
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
3498
|
+
try:
|
|
3499
|
+
flag_file.touch()
|
|
3500
|
+
except (OSError, PermissionError):
|
|
3501
|
+
# Failed to create flag, fall back to timestamp-only coordination
|
|
3502
|
+
pass
|
|
3503
|
+
|
|
3504
|
+
# Set timestamp (backup mechanism)
|
|
3505
|
+
updates['last_user_input'] = time.time()
|
|
3506
|
+
update_instance_position(instance_name, updates)
|
|
3507
|
+
|
|
3508
|
+
# Wait for Stop hook to delete flag file
|
|
3509
|
+
wait_for_stop_exit(instance_name)
|
|
2932
3510
|
|
|
2933
|
-
send_cmd = build_send_command('your message')
|
|
2934
|
-
resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
|
|
2935
3511
|
# Build message based on what happened
|
|
2936
3512
|
msg = None
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
)
|
|
2947
|
-
else:
|
|
2948
|
-
# Check if we need to announce alias (normal startup)
|
|
2949
|
-
instance_data = load_instance_position(instance_name)
|
|
2950
|
-
alias_announced = instance_data.get('alias_announced', False)
|
|
2951
|
-
if not alias_announced:
|
|
2952
|
-
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}]"
|
|
3513
|
+
|
|
3514
|
+
# Determine if this is an HCOM-launched instance
|
|
3515
|
+
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
3516
|
+
|
|
3517
|
+
# Show bootstrap if not already announced
|
|
3518
|
+
if not alias_announced:
|
|
3519
|
+
if is_hcom_launched:
|
|
3520
|
+
# HCOM-launched instance - show bootstrap immediately
|
|
3521
|
+
msg = build_hcom_bootstrap_text(instance_name)
|
|
2953
3522
|
update_instance_position(instance_name, {'alias_announced': True})
|
|
3523
|
+
else:
|
|
3524
|
+
# Vanilla Claude instance - check if user is about to run an hcom command
|
|
3525
|
+
user_prompt = hook_data.get('prompt', '')
|
|
3526
|
+
hcom_command_pattern = r'\bhcom\s+\w+'
|
|
3527
|
+
if re.search(hcom_command_pattern, user_prompt, re.IGNORECASE):
|
|
3528
|
+
# Bootstrap not shown yet - show it preemptively before hcom command runs
|
|
3529
|
+
msg = "[HCOM COMMAND DETECTED]\n\n"
|
|
3530
|
+
msg += build_hcom_bootstrap_text(instance_name)
|
|
3531
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
3532
|
+
|
|
3533
|
+
# Add resume status note if we showed bootstrap for a matched resume
|
|
3534
|
+
if msg and is_matched_resume:
|
|
3535
|
+
if is_enabled:
|
|
3536
|
+
msg += "\n[Session resumed. HCOM started for this instance - will receive chat messages. Your alias and conversation history preserved.]"
|
|
3537
|
+
else:
|
|
3538
|
+
msg += "\n[Session resumed. HCOM stopped for this instance - will not receive chat messages. Run 'hcom start' to rejoin chat. Your alias and conversation history preserved.]"
|
|
2954
3539
|
|
|
2955
3540
|
if msg:
|
|
2956
|
-
output = {
|
|
2957
|
-
|
|
3541
|
+
output = {
|
|
3542
|
+
"hookSpecificOutput": {
|
|
3543
|
+
"hookEventName": "UserPromptSubmit",
|
|
3544
|
+
"additionalContext": msg
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
print(json.dumps(output), file=sys.stdout)
|
|
2958
3548
|
|
|
2959
|
-
def handle_sessionstart(hook_data
|
|
2960
|
-
"""Handle SessionStart hook -
|
|
2961
|
-
|
|
3549
|
+
def handle_sessionstart(hook_data: dict[str, Any]) -> None:
|
|
3550
|
+
"""Handle SessionStart hook - initial msg & reads environment variables"""
|
|
3551
|
+
# Only show message for HCOM-launched instances
|
|
3552
|
+
if os.environ.get('HCOM_LAUNCHED') != '1':
|
|
3553
|
+
return
|
|
2962
3554
|
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
log_hook_error(f'sessionstart:source_{source}')
|
|
2966
|
-
log_hook_error(f'sessionstart:updates_{updates}')
|
|
2967
|
-
log_hook_error(f'sessionstart:hook_data_{hook_data}')
|
|
3555
|
+
# Build minimal context from environment
|
|
3556
|
+
parts = ["[HCOM active]"]
|
|
2968
3557
|
|
|
2969
|
-
|
|
2970
|
-
|
|
3558
|
+
if agent_type := os.environ.get('HCOM_SUBAGENT_TYPE'):
|
|
3559
|
+
parts.append(f"[agent: {agent_type}]")
|
|
2971
3560
|
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
if source == 'startup' or is_resume_match:
|
|
2975
|
-
update_instance_position(instance_name, updates)
|
|
2976
|
-
set_status(instance_name, 'session_start')
|
|
2977
|
-
|
|
2978
|
-
log_hook_error(f'sessionstart:instance_name_after_update_{instance_name}')
|
|
2979
|
-
|
|
2980
|
-
# Build send command using helper
|
|
2981
|
-
send_cmd = build_send_command('your message')
|
|
2982
|
-
help_text = f"[Welcome! HCOM chat active. Send messages: {send_cmd}]"
|
|
2983
|
-
|
|
2984
|
-
# Add subagent type if this is a named agent
|
|
2985
|
-
subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
|
|
2986
|
-
if subagent_type:
|
|
2987
|
-
help_text += f" [Subagent: {subagent_type}]"
|
|
2988
|
-
|
|
2989
|
-
# Add first use text only on startup
|
|
2990
|
-
if source == 'startup':
|
|
2991
|
-
first_use_text = get_config_value('first_use_text', '')
|
|
2992
|
-
if first_use_text:
|
|
2993
|
-
help_text += f" [{first_use_text}]"
|
|
2994
|
-
elif source == 'resume':
|
|
2995
|
-
if is_resume_match:
|
|
2996
|
-
help_text += f" [Resumed alias: {instance_name}]"
|
|
2997
|
-
else:
|
|
2998
|
-
help_text += f" [Session resumed]"
|
|
3561
|
+
if tag := os.environ.get('HCOM_TAG'):
|
|
3562
|
+
parts.append(f"[tag: {tag}]")
|
|
2999
3563
|
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3564
|
+
help_text = " ".join(parts)
|
|
3565
|
+
|
|
3566
|
+
# First time: no instance files or archives exist
|
|
3567
|
+
is_first_time = not any(hcom_path().rglob('*.json'))
|
|
3568
|
+
|
|
3569
|
+
is_first_time = True
|
|
3570
|
+
if is_first_time:
|
|
3571
|
+
help_text += """
|
|
3572
|
+
|
|
3573
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
3574
|
+
Welcome to Hook Comms!
|
|
3575
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
3576
|
+
Dashboard: hcom watch
|
|
3577
|
+
Toggle on/off: hcom stop / hcom start
|
|
3578
|
+
Launch: hcom 3
|
|
3579
|
+
All commands: hcom help
|
|
3580
|
+
Config: ~/.hcom/config.env
|
|
3581
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
3582
|
+
|
|
3583
|
+
"""
|
|
3004
3584
|
|
|
3005
|
-
# Output as additionalContext using hookSpecificOutput format
|
|
3006
3585
|
output = {
|
|
3007
3586
|
"hookSpecificOutput": {
|
|
3008
3587
|
"hookEventName": "SessionStart",
|
|
3009
3588
|
"additionalContext": help_text
|
|
3010
3589
|
}
|
|
3011
3590
|
}
|
|
3591
|
+
|
|
3012
3592
|
print(json.dumps(output))
|
|
3013
3593
|
|
|
3594
|
+
def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3595
|
+
"""Handle SessionEnd hook - mark session as ended and set final status"""
|
|
3596
|
+
reason = hook_data.get('reason', 'unknown')
|
|
3597
|
+
|
|
3598
|
+
# Set session_ended flag to tell Stop hook to exit
|
|
3599
|
+
updates['session_ended'] = True
|
|
3600
|
+
|
|
3601
|
+
# Set status with reason as context (reason: clear, logout, prompt_input_exit, other)
|
|
3602
|
+
set_status(instance_name, 'session_ended', reason)
|
|
3603
|
+
|
|
3604
|
+
try:
|
|
3605
|
+
update_instance_position(instance_name, updates)
|
|
3606
|
+
except Exception as e:
|
|
3607
|
+
log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
|
|
3608
|
+
|
|
3609
|
+
def should_skip_vanilla_instance(hook_type: str, hook_data: dict) -> bool:
|
|
3610
|
+
"""
|
|
3611
|
+
Returns True if hook should exit early.
|
|
3612
|
+
Vanilla instances (not HCOM-launched) exit early unless:
|
|
3613
|
+
- Enabled
|
|
3614
|
+
- PreToolUse (handles opt-in)
|
|
3615
|
+
- UserPromptSubmit with hcom command in prompt (shows preemptive bootstrap)
|
|
3616
|
+
"""
|
|
3617
|
+
# PreToolUse always runs (handles toggle commands)
|
|
3618
|
+
# HCOM-launched instances always run
|
|
3619
|
+
if hook_type == 'pre' or os.environ.get('HCOM_LAUNCHED') == '1':
|
|
3620
|
+
return False
|
|
3621
|
+
|
|
3622
|
+
session_id = hook_data.get('session_id', '')
|
|
3623
|
+
if not session_id: # No session_id = can't identify instance, skip hook
|
|
3624
|
+
return True
|
|
3625
|
+
|
|
3626
|
+
instance_name = get_display_name(session_id, get_config().tag)
|
|
3627
|
+
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
3628
|
+
|
|
3629
|
+
if not instance_file.exists():
|
|
3630
|
+
# Allow UserPromptSubmit if prompt contains hcom command
|
|
3631
|
+
if hook_type == 'userpromptsubmit':
|
|
3632
|
+
user_prompt = hook_data.get('prompt', '')
|
|
3633
|
+
return not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE)
|
|
3634
|
+
return True
|
|
3635
|
+
|
|
3636
|
+
return False
|
|
3637
|
+
|
|
3014
3638
|
def handle_hook(hook_type: str) -> None:
|
|
3015
3639
|
"""Unified hook handler for all HCOM hooks"""
|
|
3016
|
-
|
|
3640
|
+
hook_data = json.load(sys.stdin)
|
|
3641
|
+
|
|
3642
|
+
if not ensure_hcom_directories():
|
|
3643
|
+
log_hook_error('handle_hook', Exception('Failed to create directories'))
|
|
3017
3644
|
sys.exit(EXIT_SUCCESS)
|
|
3018
3645
|
|
|
3019
|
-
|
|
3020
|
-
|
|
3646
|
+
# SessionStart is standalone - no instance files
|
|
3647
|
+
if hook_type == 'sessionstart':
|
|
3648
|
+
handle_sessionstart(hook_data)
|
|
3649
|
+
sys.exit(EXIT_SUCCESS)
|
|
3650
|
+
|
|
3651
|
+
# Vanilla instance check - exit early if should skip
|
|
3652
|
+
if should_skip_vanilla_instance(hook_type, hook_data):
|
|
3653
|
+
sys.exit(EXIT_SUCCESS)
|
|
3654
|
+
|
|
3655
|
+
# Initialize instance context (creates file if needed, reuses existing if session_id matches)
|
|
3656
|
+
instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
|
|
3657
|
+
|
|
3658
|
+
# Load instance data once (for enabled check and to pass to handlers)
|
|
3659
|
+
instance_data = None
|
|
3660
|
+
if hook_type != 'pre':
|
|
3661
|
+
instance_data = load_instance_position(instance_name)
|
|
3021
3662
|
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3663
|
+
# Skip enabled check for UserPromptSubmit when bootstrap needs to be shown
|
|
3664
|
+
# (alias_announced=false means bootstrap hasn't been shown yet)
|
|
3665
|
+
skip_enabled_check = (hook_type == 'userpromptsubmit' and
|
|
3666
|
+
not instance_data.get('alias_announced', False))
|
|
3025
3667
|
|
|
3026
|
-
|
|
3668
|
+
if not skip_enabled_check and not instance_data.get('enabled', False):
|
|
3669
|
+
sys.exit(EXIT_SUCCESS)
|
|
3027
3670
|
|
|
3028
3671
|
match hook_type:
|
|
3029
3672
|
case 'pre':
|
|
3030
|
-
handle_pretooluse(hook_data, instance_name
|
|
3031
|
-
case '
|
|
3032
|
-
handle_stop(hook_data, instance_name, updates)
|
|
3673
|
+
handle_pretooluse(hook_data, instance_name)
|
|
3674
|
+
case 'poll':
|
|
3675
|
+
handle_stop(hook_data, instance_name, updates, instance_data)
|
|
3033
3676
|
case 'notify':
|
|
3034
|
-
handle_notify(hook_data, instance_name, updates)
|
|
3677
|
+
handle_notify(hook_data, instance_name, updates, instance_data)
|
|
3035
3678
|
case 'userpromptsubmit':
|
|
3036
|
-
handle_userpromptsubmit(hook_data, instance_name, updates,
|
|
3037
|
-
case '
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
log_hook_error(f'handle_hook:instance_name_{instance_name}')
|
|
3041
|
-
|
|
3679
|
+
handle_userpromptsubmit(hook_data, instance_name, updates, is_matched_resume, instance_data)
|
|
3680
|
+
case 'sessionend':
|
|
3681
|
+
handle_sessionend(hook_data, instance_name, updates, instance_data)
|
|
3042
3682
|
|
|
3043
3683
|
sys.exit(EXIT_SUCCESS)
|
|
3044
3684
|
|
|
3045
3685
|
|
|
3046
3686
|
# ==================== Main Entry Point ====================
|
|
3047
3687
|
|
|
3048
|
-
def main(argv=None):
|
|
3688
|
+
def main(argv: list[str] | None = None) -> int | None:
|
|
3049
3689
|
"""Main command dispatcher"""
|
|
3050
3690
|
if argv is None:
|
|
3051
|
-
argv = sys.argv
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3691
|
+
argv = sys.argv[1:]
|
|
3692
|
+
else:
|
|
3693
|
+
argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
|
|
3694
|
+
|
|
3695
|
+
# Hook handlers only (called BY hooks, not users)
|
|
3696
|
+
if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
|
|
3697
|
+
handle_hook(argv[0])
|
|
3698
|
+
return 0
|
|
3699
|
+
|
|
3700
|
+
# Check for updates (CLI commands only, not hooks)
|
|
3701
|
+
check_version_once_daily()
|
|
3702
|
+
|
|
3703
|
+
# Ensure directories exist
|
|
3704
|
+
if not ensure_hcom_directories():
|
|
3705
|
+
print(format_error("Failed to create HCOM directories"), file=sys.stderr)
|
|
3706
|
+
return 1
|
|
3707
|
+
|
|
3708
|
+
# Ensure hooks current (warns but never blocks)
|
|
3709
|
+
ensure_hooks_current()
|
|
3710
|
+
|
|
3711
|
+
# Route to commands
|
|
3712
|
+
try:
|
|
3713
|
+
if not argv or argv[0] in ('help', '--help', '-h'):
|
|
3060
3714
|
return cmd_help()
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
case 'watch':
|
|
3064
|
-
return cmd_watch(*argv[2:])
|
|
3065
|
-
case 'clear':
|
|
3066
|
-
return cmd_clear()
|
|
3067
|
-
case 'cleanup':
|
|
3068
|
-
return cmd_cleanup(*argv[2:])
|
|
3069
|
-
case 'send':
|
|
3070
|
-
if len(argv) < 3:
|
|
3715
|
+
elif argv[0] == 'send_cli':
|
|
3716
|
+
if len(argv) < 2:
|
|
3071
3717
|
print(format_error("Message required"), file=sys.stderr)
|
|
3072
3718
|
return 1
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
return
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3719
|
+
return send_cli(argv[1])
|
|
3720
|
+
elif argv[0] == 'watch':
|
|
3721
|
+
return cmd_watch(argv[1:])
|
|
3722
|
+
elif argv[0] == 'send':
|
|
3723
|
+
return cmd_send(argv[1:])
|
|
3724
|
+
elif argv[0] == 'stop':
|
|
3725
|
+
return cmd_stop(argv[1:])
|
|
3726
|
+
elif argv[0] == 'start':
|
|
3727
|
+
return cmd_start(argv[1:])
|
|
3728
|
+
elif argv[0] == 'reset':
|
|
3729
|
+
return cmd_reset(argv[1:])
|
|
3730
|
+
elif argv[0].isdigit() or argv[0] == 'claude':
|
|
3731
|
+
# Launch instances: hcom <1-100> [args] or hcom claude [args]
|
|
3732
|
+
return cmd_launch(argv)
|
|
3733
|
+
else:
|
|
3734
|
+
print(format_error(
|
|
3735
|
+
f"Unknown command: {argv[0]}",
|
|
3736
|
+
"Run 'hcom --help' for usage"
|
|
3737
|
+
), file=sys.stderr)
|
|
3092
3738
|
return 1
|
|
3739
|
+
except CLIError as exc:
|
|
3740
|
+
print(str(exc), file=sys.stderr)
|
|
3741
|
+
return 1
|
|
3093
3742
|
|
|
3094
3743
|
if __name__ == '__main__':
|
|
3095
3744
|
sys.exit(main())
|