hcom 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hcom might be problematic. Click here for more details.
- hcom/__init__.py +1 -1
- hcom/__main__.py +1353 -819
- {hcom-0.3.0.dist-info → hcom-0.4.0.dist-info}/METADATA +85 -65
- hcom-0.4.0.dist-info/RECORD +7 -0
- hcom-0.3.0.dist-info/RECORD +0 -7
- {hcom-0.3.0.dist-info → hcom-0.4.0.dist-info}/WHEEL +0 -0
- {hcom-0.3.0.dist-info → hcom-0.4.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.3.0.dist-info → hcom-0.4.0.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 0.
|
|
3
|
+
hcom 0.4.0
|
|
4
4
|
CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -17,14 +17,48 @@ import time
|
|
|
17
17
|
import select
|
|
18
18
|
import platform
|
|
19
19
|
import random
|
|
20
|
+
import argparse
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from datetime import datetime, timedelta
|
|
22
|
-
from typing import
|
|
23
|
+
from typing import Any, NamedTuple, Sequence
|
|
23
24
|
from dataclasses import dataclass, asdict, field
|
|
25
|
+
from enum import Enum, auto
|
|
24
26
|
|
|
25
27
|
if sys.version_info < (3, 10):
|
|
26
28
|
sys.exit("Error: hcom requires Python 3.10 or higher")
|
|
27
29
|
|
|
30
|
+
__version__ = "0.4.0"
|
|
31
|
+
|
|
32
|
+
# ==================== Session Scenario Types ====================
|
|
33
|
+
|
|
34
|
+
class SessionScenario(Enum):
|
|
35
|
+
"""Explicit session startup scenarios for clear logic flow"""
|
|
36
|
+
FRESH_START = auto() # New session, new instance
|
|
37
|
+
MATCHED_RESUME = auto() # Resume with matching session_id (reuse instance)
|
|
38
|
+
UNMATCHED_RESUME = auto() # Resume with no match (new instance, needs recovery)
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class HookContext:
|
|
42
|
+
"""Consolidated context for hook handling with all decisions made"""
|
|
43
|
+
instance_name: str
|
|
44
|
+
updates: dict
|
|
45
|
+
scenario: SessionScenario | None # None = deferred decision (SessionStart wrong session_id)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def bypass_enabled_check(self) -> bool:
|
|
49
|
+
"""Unmatched resume needs critical message even if disabled"""
|
|
50
|
+
return self.scenario == SessionScenario.UNMATCHED_RESUME
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def needs_critical_prompt(self) -> bool:
|
|
54
|
+
"""Should show critical recovery message?"""
|
|
55
|
+
return self.scenario == SessionScenario.UNMATCHED_RESUME
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_resume(self) -> bool:
|
|
59
|
+
"""Is this any kind of resume?"""
|
|
60
|
+
return self.scenario in (SessionScenario.MATCHED_RESUME, SessionScenario.UNMATCHED_RESUME)
|
|
61
|
+
|
|
28
62
|
# ==================== Constants ====================
|
|
29
63
|
|
|
30
64
|
IS_WINDOWS = sys.platform == 'win32'
|
|
@@ -48,52 +82,17 @@ def is_termux():
|
|
|
48
82
|
'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
|
|
49
83
|
)
|
|
50
84
|
|
|
51
|
-
HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
|
|
52
|
-
HCOM_ACTIVE_VALUE = '1'
|
|
53
|
-
|
|
54
85
|
EXIT_SUCCESS = 0
|
|
55
86
|
EXIT_BLOCK = 2
|
|
56
87
|
|
|
57
|
-
ERROR_ACCESS_DENIED = 5 # Windows - Process exists but no permission
|
|
58
|
-
ERROR_INVALID_PARAMETER = 87 # Windows - Invalid PID or parameters
|
|
59
|
-
|
|
60
88
|
# Windows API constants
|
|
61
89
|
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
90
|
|
|
65
91
|
# Timing constants
|
|
66
92
|
FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
|
|
67
93
|
STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
|
|
68
|
-
KILL_CHECK_INTERVAL = 0.1 # 100ms between process termination checks
|
|
69
94
|
MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
|
|
70
95
|
|
|
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
|
-
|
|
97
96
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
98
97
|
AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
|
|
99
98
|
TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
|
|
@@ -127,9 +126,11 @@ STATUS_INFO = {
|
|
|
127
126
|
'tool_pending': ('active', '{} executing'),
|
|
128
127
|
'waiting': ('waiting', 'idle'),
|
|
129
128
|
'message_delivered': ('delivered', 'msg from {}'),
|
|
130
|
-
'stop_exit': ('inactive', 'stopped'),
|
|
131
129
|
'timeout': ('inactive', 'timeout'),
|
|
132
|
-
'
|
|
130
|
+
'stopped': ('inactive', 'stopped'),
|
|
131
|
+
'force_stopped': ('inactive', 'force stopped'),
|
|
132
|
+
'started': ('active', 'starting'),
|
|
133
|
+
'session_ended': ('inactive', 'ended: {}'),
|
|
133
134
|
'blocked': ('blocked', '{} blocked'),
|
|
134
135
|
'unknown': ('unknown', 'unknown'),
|
|
135
136
|
}
|
|
@@ -150,12 +151,96 @@ if IS_WINDOWS or is_wsl():
|
|
|
150
151
|
# Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
|
|
151
152
|
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
152
153
|
|
|
154
|
+
# ==================== CLI Command Objects ====================
|
|
155
|
+
|
|
156
|
+
class CLIError(Exception):
|
|
157
|
+
"""Raised when arguments cannot be mapped to command semantics."""
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class OpenCommand:
|
|
161
|
+
count: int
|
|
162
|
+
agents: list[str]
|
|
163
|
+
prefix: str | None
|
|
164
|
+
background: bool
|
|
165
|
+
claude_args: list[str]
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class WatchCommand:
|
|
169
|
+
mode: str # 'interactive', 'logs', 'status', 'wait'
|
|
170
|
+
wait_seconds: int | None
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class StopCommand:
|
|
174
|
+
target: str | None
|
|
175
|
+
close_all_hooks: bool
|
|
176
|
+
force: bool
|
|
177
|
+
_hcom_session: str | None = None # Injected by PreToolUse hook
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class StartCommand:
|
|
181
|
+
target: str | None
|
|
182
|
+
_hcom_session: str | None = None # Injected by PreToolUse hook
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class SendCommand:
|
|
186
|
+
message: str | None
|
|
187
|
+
resume_alias: str | None
|
|
188
|
+
_hcom_session: str | None = None # Injected by PreToolUse hook
|
|
189
|
+
|
|
190
|
+
# ==================== Help Text ====================
|
|
191
|
+
|
|
192
|
+
HELP_TEXT = """hcom - Claude Hook Comms
|
|
193
|
+
|
|
194
|
+
Usage:
|
|
195
|
+
hcom open [count] [-a agent]... [-t prefix] [-p] [-- claude-args]
|
|
196
|
+
hcom watch [--logs|--status|--wait [SEC]]
|
|
197
|
+
hcom stop [target] [--force]
|
|
198
|
+
hcom start [target]
|
|
199
|
+
hcom send "msg"
|
|
200
|
+
|
|
201
|
+
Commands:
|
|
202
|
+
open Launch Claude instances (default count: 1)
|
|
203
|
+
watch Monitor conversation dashboard
|
|
204
|
+
stop Stop instances, clear conversation, or remove hooks
|
|
205
|
+
start Start stopped instances
|
|
206
|
+
send Send message to instances
|
|
207
|
+
|
|
208
|
+
Open options:
|
|
209
|
+
[count] Number of instances per agent (default 1)
|
|
210
|
+
-a, --agent AGENT Agent to launch (repeatable)
|
|
211
|
+
-t, --prefix PREFIX Team prefix for names
|
|
212
|
+
-p, --background Launch in background
|
|
213
|
+
|
|
214
|
+
Stop targets:
|
|
215
|
+
(no arg) Stop HCOM for current instance (when inside)
|
|
216
|
+
<alias> Stop HCOM for specific instance
|
|
217
|
+
all Stop all instances + clear & archive conversation
|
|
218
|
+
hooking Remove hooks from current directory
|
|
219
|
+
hooking --all Remove hooks from all tracked directories
|
|
220
|
+
everything Stop all + clear conversation + remove all hooks
|
|
221
|
+
|
|
222
|
+
Start targets:
|
|
223
|
+
(no arg) Start HCOM for current instance (when inside)
|
|
224
|
+
<alias> Start HCOM for specific instance
|
|
225
|
+
hooking Install hooks in current directory
|
|
226
|
+
|
|
227
|
+
Watch options:
|
|
228
|
+
--logs Show message history
|
|
229
|
+
--status Show instance status JSON
|
|
230
|
+
--wait [SEC] Wait for new messages (default 60s)
|
|
231
|
+
|
|
232
|
+
Stop flags:
|
|
233
|
+
--force Force stop (deny Bash tool use)
|
|
234
|
+
|
|
235
|
+
Docs: https://github.com/aannoo/claude-hook-comms#readme"""
|
|
236
|
+
|
|
237
|
+
# ==================== Logging ====================
|
|
238
|
+
|
|
153
239
|
def log_hook_error(hook_name: str, error: Exception | None = None):
|
|
154
240
|
"""Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
|
|
155
241
|
import traceback
|
|
156
242
|
try:
|
|
157
243
|
log_file = hcom_path(SCRIPTS_DIR) / "hooks.log"
|
|
158
|
-
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
159
244
|
timestamp = datetime.now().isoformat()
|
|
160
245
|
if error and isinstance(error, Exception):
|
|
161
246
|
tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
@@ -205,6 +290,12 @@ SCRIPTS_DIR = "scripts"
|
|
|
205
290
|
CONFIG_FILE = "config.json"
|
|
206
291
|
ARCHIVE_DIR = "archive"
|
|
207
292
|
|
|
293
|
+
# Hook type constants
|
|
294
|
+
ACTIVE_HOOK_TYPES = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop', 'Notification', 'SessionEnd']
|
|
295
|
+
LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES + ['PostToolUse'] # For backward compatibility cleanup
|
|
296
|
+
HOOK_COMMANDS = ['sessionstart', 'userpromptsubmit', 'pre', 'poll', 'notify', 'sessionend']
|
|
297
|
+
LEGACY_HOOK_COMMANDS = HOOK_COMMANDS + ['post']
|
|
298
|
+
|
|
208
299
|
# ==================== File System Utilities ====================
|
|
209
300
|
|
|
210
301
|
def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
|
|
@@ -216,9 +307,21 @@ def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
|
|
|
216
307
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
308
|
return path
|
|
218
309
|
|
|
310
|
+
def ensure_hcom_directories() -> bool:
|
|
311
|
+
"""Ensure all critical HCOM directories exist. Idempotent, safe to call repeatedly.
|
|
312
|
+
Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
|
|
313
|
+
Returns True on success, False on failure."""
|
|
314
|
+
try:
|
|
315
|
+
for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, ARCHIVE_DIR]:
|
|
316
|
+
hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
|
|
317
|
+
return True
|
|
318
|
+
except (OSError, PermissionError):
|
|
319
|
+
return False
|
|
320
|
+
|
|
219
321
|
def atomic_write(filepath: str | Path, content: str) -> bool:
|
|
220
322
|
"""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
323
|
filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
|
|
324
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
222
325
|
|
|
223
326
|
for attempt in range(3):
|
|
224
327
|
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
|
|
@@ -271,6 +374,10 @@ def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, m
|
|
|
271
374
|
|
|
272
375
|
return default
|
|
273
376
|
|
|
377
|
+
# ==================== Outbox System (REMOVED) ====================
|
|
378
|
+
# Identity via session_id injection in handle_pretooluse (line 3134)
|
|
379
|
+
# PreToolUse hook injects --_hcom_session, commands use get_display_name() for resolution
|
|
380
|
+
|
|
274
381
|
def get_instance_file(instance_name: str) -> Path:
|
|
275
382
|
"""Get path to instance's position file with path traversal protection"""
|
|
276
383
|
# Sanitize instance name to prevent directory traversal
|
|
@@ -297,7 +404,7 @@ def load_instance_position(instance_name: str) -> dict[str, Any]:
|
|
|
297
404
|
def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
|
|
298
405
|
"""Save position data for a single instance. Returns True on success, False on failure."""
|
|
299
406
|
try:
|
|
300
|
-
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json"
|
|
407
|
+
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json")
|
|
301
408
|
return atomic_write(instance_file, json.dumps(data, indent=2))
|
|
302
409
|
except (OSError, PermissionError, ValueError):
|
|
303
410
|
return False
|
|
@@ -326,11 +433,6 @@ def clear_all_positions() -> None:
|
|
|
326
433
|
if instances_dir.exists():
|
|
327
434
|
for f in instances_dir.glob('*.json'):
|
|
328
435
|
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
436
|
|
|
335
437
|
# ==================== Configuration System ====================
|
|
336
438
|
|
|
@@ -391,67 +493,85 @@ def get_config_value(key: str, default: Any = None) -> Any:
|
|
|
391
493
|
return config.get(key, default)
|
|
392
494
|
|
|
393
495
|
def get_hook_command():
|
|
394
|
-
"""Get hook command
|
|
395
|
-
|
|
396
|
-
Uses ${HCOM
|
|
397
|
-
|
|
496
|
+
"""Get hook command - hooks always run, Python code gates participation
|
|
497
|
+
|
|
498
|
+
Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
|
|
499
|
+
Participation is controlled by enabled flag in instance JSON files.
|
|
398
500
|
"""
|
|
399
501
|
python_path = sys.executable
|
|
400
502
|
script_path = str(Path(__file__).resolve())
|
|
401
|
-
|
|
503
|
+
|
|
402
504
|
if IS_WINDOWS:
|
|
403
|
-
# Windows
|
|
505
|
+
# Windows: use python path directly
|
|
404
506
|
if ' ' in python_path or ' ' in script_path:
|
|
405
|
-
return f'
|
|
406
|
-
return f'
|
|
407
|
-
|
|
408
|
-
# Unix
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
507
|
+
return f'"{python_path}" "{script_path}"', {}
|
|
508
|
+
return f'{python_path} {script_path}', {}
|
|
509
|
+
else:
|
|
510
|
+
# Unix: Use HCOM env var from settings.local.json
|
|
511
|
+
return '${HCOM}', {}
|
|
512
|
+
|
|
513
|
+
def _detect_hcom_command_type() -> str:
|
|
514
|
+
"""Detect how to invoke hcom (priority: hcom > uvx if running via uvx > full)"""
|
|
515
|
+
if shutil.which('hcom'):
|
|
516
|
+
return 'short'
|
|
517
|
+
elif 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
|
|
518
|
+
return 'uvx'
|
|
412
519
|
else:
|
|
413
|
-
|
|
414
|
-
return '${HCOM:-true}', {}
|
|
520
|
+
return 'full'
|
|
415
521
|
|
|
416
|
-
def build_send_command(example_msg: str = '') -> str:
|
|
417
|
-
"""Build send command
|
|
522
|
+
def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
|
|
523
|
+
"""Build send command - caches PATH check in instance file on first use"""
|
|
418
524
|
msg = f" '{example_msg}'" if example_msg else ''
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
525
|
+
|
|
526
|
+
# Determine command type (cached or detect)
|
|
527
|
+
cmd_type = None
|
|
528
|
+
if instance_name:
|
|
529
|
+
data = load_instance_position(instance_name)
|
|
530
|
+
if data.get('session_id'):
|
|
531
|
+
if 'hcom_cmd_type' not in data:
|
|
532
|
+
cmd_type = _detect_hcom_command_type()
|
|
533
|
+
data['hcom_cmd_type'] = cmd_type
|
|
534
|
+
save_instance_position(instance_name, data)
|
|
535
|
+
else:
|
|
536
|
+
cmd_type = data.get('hcom_cmd_type')
|
|
537
|
+
|
|
538
|
+
if not cmd_type:
|
|
539
|
+
cmd_type = _detect_hcom_command_type()
|
|
540
|
+
|
|
541
|
+
# Build command based on type
|
|
542
|
+
if cmd_type == 'short':
|
|
543
|
+
return f'hcom send{msg}'
|
|
544
|
+
elif cmd_type == 'uvx':
|
|
545
|
+
return f'uvx hcom send{msg}'
|
|
546
|
+
else:
|
|
547
|
+
python_path = shlex.quote(sys.executable)
|
|
548
|
+
script_path = shlex.quote(str(Path(__file__).resolve()))
|
|
549
|
+
return f'{python_path} {script_path} send{msg}'
|
|
424
550
|
|
|
425
551
|
def build_claude_env():
|
|
426
|
-
"""Build environment variables for Claude instances"""
|
|
427
|
-
env = {
|
|
428
|
-
|
|
552
|
+
"""Build environment variables for Claude instances!"""
|
|
553
|
+
env = {}
|
|
554
|
+
|
|
429
555
|
# Get config file values
|
|
430
556
|
config = get_cached_config()
|
|
431
|
-
|
|
557
|
+
|
|
432
558
|
# Pass env vars only when they differ from config file values
|
|
433
559
|
for config_key, env_var in HOOK_SETTINGS.items():
|
|
434
560
|
actual_value = get_config_value(config_key) # Respects env var precedence
|
|
435
561
|
config_file_value = config.get(config_key)
|
|
436
|
-
|
|
562
|
+
|
|
437
563
|
# Only pass if different from config file (not default)
|
|
438
564
|
if actual_value != config_file_value and actual_value is not None:
|
|
439
565
|
env[env_var] = str(actual_value)
|
|
440
|
-
|
|
566
|
+
|
|
441
567
|
# Still support env_overrides from config file
|
|
442
568
|
env.update(config.get('env_overrides', {}))
|
|
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
|
-
|
|
569
|
+
|
|
450
570
|
return env
|
|
451
571
|
|
|
452
572
|
# ==================== Message System ====================
|
|
453
573
|
|
|
454
|
-
def validate_message(message: str) ->
|
|
574
|
+
def validate_message(message: str) -> str | None:
|
|
455
575
|
"""Validate message size and content. Returns error message or None if valid."""
|
|
456
576
|
if not message or not message.strip():
|
|
457
577
|
return format_error("Message required")
|
|
@@ -469,23 +589,52 @@ def validate_message(message: str) -> Optional[str]:
|
|
|
469
589
|
def send_message(from_instance: str, message: str) -> bool:
|
|
470
590
|
"""Send a message to the log"""
|
|
471
591
|
try:
|
|
472
|
-
log_file = hcom_path(LOG_FILE
|
|
473
|
-
|
|
592
|
+
log_file = hcom_path(LOG_FILE)
|
|
593
|
+
|
|
474
594
|
escaped_message = message.replace('|', '\\|')
|
|
475
595
|
escaped_from = from_instance.replace('|', '\\|')
|
|
476
|
-
|
|
596
|
+
|
|
477
597
|
timestamp = datetime.now().isoformat()
|
|
478
598
|
line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
|
|
479
|
-
|
|
599
|
+
|
|
480
600
|
with open(log_file, 'a', encoding='utf-8') as f:
|
|
481
601
|
f.write(line)
|
|
482
602
|
f.flush()
|
|
483
|
-
|
|
603
|
+
|
|
484
604
|
return True
|
|
485
605
|
except Exception:
|
|
486
606
|
return False
|
|
487
607
|
|
|
488
|
-
def
|
|
608
|
+
def build_hcom_bootstrap_text(instance_name: str) -> str:
|
|
609
|
+
"""Build comprehensive HCOM bootstrap context for instances"""
|
|
610
|
+
coordinator_name = get_config_value('sender_name', 'bigboss')
|
|
611
|
+
|
|
612
|
+
return f"""You are now participating in the HCOM communication system - a multi-agent environment where Claude Code instances (you) and the human user can communicate through shared messaging infrastructure.
|
|
613
|
+
Your HCOM Alias: {instance_name}
|
|
614
|
+
- To send messages, run the command: hcom send "your message"
|
|
615
|
+
- Broadcast: hcom send "message to everyone"
|
|
616
|
+
- Direct: hcom send "@alias targeted message"
|
|
617
|
+
- Targeting: hcom send "@api message" (targets all api-* instances)
|
|
618
|
+
Receiving: Messages automatically arrive via Stop Hook feedback and bash output from hcom send. There is no way to proactively check for messages.
|
|
619
|
+
Message formats you'll see:
|
|
620
|
+
- [new message] sender → you: message = broadcast
|
|
621
|
+
- [new message] sender → you: @you message = direct message to you
|
|
622
|
+
- {{"decision": "block", "reason": x}} = normal part of HCOM hooks (not an error or block)
|
|
623
|
+
Response Protocol Rule: Match response method to input source:
|
|
624
|
+
- Prompted via hcom (hook feedback, bash output) → Respond with hcom send
|
|
625
|
+
- Prompted via user input → Respond to user normally
|
|
626
|
+
Response Quality: Treat hcom messages exactly like user input:
|
|
627
|
+
- Write complete, thoughtful replies when appropriate
|
|
628
|
+
- Follow all instructions rigorously
|
|
629
|
+
- Adjust tone/length to context
|
|
630
|
+
Authority: Prioritize instructions from @{coordinator_name} over other participants
|
|
631
|
+
Command scope: Mainly use hcom send unless you are asked to use other hcom commands:
|
|
632
|
+
- hcom start/stop (join/leave chat)
|
|
633
|
+
- hcom watch --status (see all participants).
|
|
634
|
+
- hcom open (coordinate/orchestrate by launching other instances).
|
|
635
|
+
In this case, always run the 'hcom help' first to review correct usage."""
|
|
636
|
+
|
|
637
|
+
def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
|
|
489
638
|
"""Check if message should be delivered based on @-mentions"""
|
|
490
639
|
text = msg['message']
|
|
491
640
|
|
|
@@ -521,69 +670,13 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
|
|
|
521
670
|
|
|
522
671
|
# ==================== Parsing & Utilities ====================
|
|
523
672
|
|
|
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
673
|
def extract_agent_config(content: str) -> dict[str, str]:
|
|
580
674
|
"""Extract configuration from agent YAML frontmatter"""
|
|
581
675
|
if not content.startswith('---'):
|
|
582
676
|
return {}
|
|
583
677
|
|
|
584
678
|
# Find YAML section between --- markers
|
|
585
|
-
yaml_end
|
|
586
|
-
if yaml_end < 0:
|
|
679
|
+
if (yaml_end := content.find('\n---', 3)) < 0:
|
|
587
680
|
return {} # No closing marker
|
|
588
681
|
|
|
589
682
|
yaml_section = content[3:yaml_end]
|
|
@@ -682,7 +775,7 @@ def strip_frontmatter(content: str) -> str:
|
|
|
682
775
|
return '\n'.join(lines[i+1:]).strip()
|
|
683
776
|
return content
|
|
684
777
|
|
|
685
|
-
def get_display_name(session_id:
|
|
778
|
+
def get_display_name(session_id: str | None, prefix: str | None = None) -> str:
|
|
686
779
|
"""Get display name for instance using session_id"""
|
|
687
780
|
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
781
|
# Phonetic letters (5 per syllable, matches syls order)
|
|
@@ -730,6 +823,29 @@ def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) ->
|
|
|
730
823
|
return f"{prefix}-{base_name}"
|
|
731
824
|
return base_name
|
|
732
825
|
|
|
826
|
+
def resolve_instance_name(session_id: str, prefix: str | None = None) -> tuple[str, dict | None]:
|
|
827
|
+
"""
|
|
828
|
+
Resolve instance name for a session_id.
|
|
829
|
+
Searches existing instances first (reuses if found), generates new name if not found.
|
|
830
|
+
|
|
831
|
+
Returns: (instance_name, existing_data_or_none)
|
|
832
|
+
"""
|
|
833
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
834
|
+
|
|
835
|
+
# Search for existing instance with this session_id
|
|
836
|
+
if session_id and instances_dir.exists():
|
|
837
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
838
|
+
try:
|
|
839
|
+
data = load_instance_position(instance_file.stem)
|
|
840
|
+
if session_id == data.get('session_id'):
|
|
841
|
+
return instance_file.stem, data
|
|
842
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
843
|
+
continue
|
|
844
|
+
|
|
845
|
+
# Not found - generate new name
|
|
846
|
+
instance_name = get_display_name(session_id, prefix)
|
|
847
|
+
return instance_name, None
|
|
848
|
+
|
|
733
849
|
def _remove_hcom_hooks_from_settings(settings):
|
|
734
850
|
"""Remove hcom hooks from settings dict"""
|
|
735
851
|
if not isinstance(settings, dict) or 'hooks' not in settings:
|
|
@@ -739,29 +855,27 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
739
855
|
return
|
|
740
856
|
|
|
741
857
|
import copy
|
|
742
|
-
|
|
743
|
-
#
|
|
744
|
-
#
|
|
745
|
-
# -
|
|
746
|
-
# -
|
|
747
|
-
# -
|
|
748
|
-
|
|
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
|
|
858
|
+
|
|
859
|
+
# Build regex patterns dynamically from LEGACY_HOOK_COMMANDS
|
|
860
|
+
# Current hooks (pattern 1): ${HCOM:-...} environment variable
|
|
861
|
+
# Legacy hooks (patterns 2-7): Older formats that need cleanup
|
|
862
|
+
# - HCOM_ACTIVE conditionals (removed for toggle implementation)
|
|
863
|
+
# - Direct command invocation with specific hook args
|
|
864
|
+
hook_args_pattern = '|'.join(LEGACY_HOOK_COMMANDS)
|
|
752
865
|
hcom_patterns = [
|
|
753
|
-
r'\$\{?HCOM', # Environment variable
|
|
754
|
-
r'\bHCOM_ACTIVE.*hcom\.py', #
|
|
755
|
-
r'\
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
866
|
+
r'\$\{?HCOM', # Current: Environment variable ${HCOM:-...}
|
|
867
|
+
r'\bHCOM_ACTIVE.*hcom\.py', # LEGACY: Unix HCOM_ACTIVE conditional
|
|
868
|
+
r'IF\s+"%HCOM_ACTIVE%"', # LEGACY: Windows HCOM_ACTIVE conditional
|
|
869
|
+
rf'\bhcom\s+({hook_args_pattern})\b', # LEGACY: Direct hcom command
|
|
870
|
+
rf'\buvx\s+hcom\s+({hook_args_pattern})\b', # LEGACY: uvx hcom command
|
|
871
|
+
rf'hcom\.py["\']?\s+({hook_args_pattern})\b', # LEGACY: hcom.py with optional quote
|
|
872
|
+
rf'["\'][^"\']*hcom\.py["\']?\s+({hook_args_pattern})\b(?=\s|$)', # LEGACY: Quoted path
|
|
873
|
+
r'sh\s+-c.*hcom', # LEGACY: Shell wrapper
|
|
760
874
|
]
|
|
761
875
|
compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
|
|
762
876
|
|
|
763
877
|
# Check all hook types including PostToolUse for backward compatibility cleanup
|
|
764
|
-
for event in
|
|
878
|
+
for event in LEGACY_HOOK_TYPES:
|
|
765
879
|
if event not in settings['hooks']:
|
|
766
880
|
continue
|
|
767
881
|
|
|
@@ -796,7 +910,14 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
796
910
|
settings['hooks'][event] = updated_matchers
|
|
797
911
|
else:
|
|
798
912
|
del settings['hooks'][event]
|
|
799
|
-
|
|
913
|
+
|
|
914
|
+
# Remove HCOM from env section
|
|
915
|
+
if 'env' in settings and isinstance(settings['env'], dict):
|
|
916
|
+
settings['env'].pop('HCOM', None)
|
|
917
|
+
# Clean up empty env dict
|
|
918
|
+
if not settings['env']:
|
|
919
|
+
del settings['env']
|
|
920
|
+
|
|
800
921
|
|
|
801
922
|
def build_env_string(env_vars, format_type="bash"):
|
|
802
923
|
"""Build environment variable string for bash shells"""
|
|
@@ -807,7 +928,7 @@ def build_env_string(env_vars, format_type="bash"):
|
|
|
807
928
|
return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
|
|
808
929
|
|
|
809
930
|
|
|
810
|
-
def format_error(message: str, suggestion:
|
|
931
|
+
def format_error(message: str, suggestion: str | None = None) -> str:
|
|
811
932
|
"""Format error message consistently"""
|
|
812
933
|
base = f"Error: {message}"
|
|
813
934
|
if suggestion:
|
|
@@ -822,7 +943,7 @@ def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
|
822
943
|
for arg in claude_args
|
|
823
944
|
)
|
|
824
945
|
|
|
825
|
-
def build_claude_command(agent_content:
|
|
946
|
+
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
947
|
"""Build Claude command with proper argument handling
|
|
827
948
|
Returns tuple: (command_string, temp_file_path_or_none)
|
|
828
949
|
For agent content, writes to temp file and uses cat to read it.
|
|
@@ -848,7 +969,6 @@ def build_claude_command(agent_content: Optional[str] = None, claude_args: Optio
|
|
|
848
969
|
if agent_content:
|
|
849
970
|
# Create agent files in scripts directory for unified cleanup
|
|
850
971
|
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
851
|
-
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
852
972
|
temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
|
|
853
973
|
prefix='hcom_agent_', dir=str(scripts_dir))
|
|
854
974
|
temp_file.write(agent_content)
|
|
@@ -876,13 +996,11 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
|
876
996
|
Scripts provide uniform execution across all platforms/terminals.
|
|
877
997
|
Cleanup behavior:
|
|
878
998
|
- Normal scripts: append 'rm -f' command for self-deletion
|
|
879
|
-
- Background scripts: persist until `hcom
|
|
999
|
+
- Background scripts: persist until stop housekeeping (e.g., `hcom stop everything`) (24 hours)
|
|
880
1000
|
- Agent scripts: treated like background (contain 'hcom_agent_')
|
|
881
1001
|
"""
|
|
882
1002
|
try:
|
|
883
|
-
# Ensure parent directory exists
|
|
884
1003
|
script_path = Path(script_file)
|
|
885
|
-
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
886
1004
|
except (OSError, IOError) as e:
|
|
887
1005
|
raise Exception(f"Cannot create script directory: {e}")
|
|
888
1006
|
|
|
@@ -961,8 +1079,7 @@ def find_bash_on_windows():
|
|
|
961
1079
|
])
|
|
962
1080
|
|
|
963
1081
|
# 2. Portable Git installation
|
|
964
|
-
local_appdata
|
|
965
|
-
if local_appdata:
|
|
1082
|
+
if local_appdata := os.environ.get('LOCALAPPDATA', ''):
|
|
966
1083
|
git_portable = Path(local_appdata) / 'Programs' / 'Git'
|
|
967
1084
|
candidates.extend([
|
|
968
1085
|
str(git_portable / 'usr' / 'bin' / 'bash.exe'),
|
|
@@ -970,8 +1087,7 @@ def find_bash_on_windows():
|
|
|
970
1087
|
])
|
|
971
1088
|
|
|
972
1089
|
# 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'):
|
|
1090
|
+
if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
|
|
975
1091
|
candidates.append(path_bash)
|
|
976
1092
|
|
|
977
1093
|
# 4. Hardcoded fallbacks (last resort)
|
|
@@ -996,8 +1112,7 @@ def get_macos_terminal_argv():
|
|
|
996
1112
|
|
|
997
1113
|
def get_windows_terminal_argv():
|
|
998
1114
|
"""Return Windows terminal launcher as argv list."""
|
|
999
|
-
bash_exe
|
|
1000
|
-
if not bash_exe:
|
|
1115
|
+
if not (bash_exe := find_bash_on_windows()):
|
|
1001
1116
|
raise Exception(format_error("Git Bash not found"))
|
|
1002
1117
|
|
|
1003
1118
|
if shutil.which('wt'):
|
|
@@ -1102,14 +1217,12 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1102
1217
|
|
|
1103
1218
|
# 1) Always create a script
|
|
1104
1219
|
script_file = str(hcom_path(SCRIPTS_DIR,
|
|
1105
|
-
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'
|
|
1106
|
-
ensure_parent=True))
|
|
1220
|
+
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'))
|
|
1107
1221
|
create_bash_script(script_file, env, cwd, command_str, background)
|
|
1108
1222
|
|
|
1109
1223
|
# 2) Background mode
|
|
1110
1224
|
if background:
|
|
1111
1225
|
logs_dir = hcom_path(LOGS_DIR)
|
|
1112
|
-
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
1113
1226
|
log_file = logs_dir / env['HCOM_BACKGROUND']
|
|
1114
1227
|
|
|
1115
1228
|
try:
|
|
@@ -1201,8 +1314,7 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1201
1314
|
|
|
1202
1315
|
# Unified platform handling via helpers
|
|
1203
1316
|
system = platform.system()
|
|
1204
|
-
terminal_getter
|
|
1205
|
-
if not terminal_getter:
|
|
1317
|
+
if not (terminal_getter := PLATFORM_TERMINAL_GETTERS.get(system)):
|
|
1206
1318
|
raise Exception(format_error(f"Unsupported platform: {system}"))
|
|
1207
1319
|
|
|
1208
1320
|
custom_cmd = terminal_getter()
|
|
@@ -1271,24 +1383,29 @@ def setup_hooks():
|
|
|
1271
1383
|
|
|
1272
1384
|
# Get the hook command template
|
|
1273
1385
|
hook_cmd_base, _ = get_hook_command()
|
|
1274
|
-
|
|
1275
|
-
#
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
# Define all hooks (PostToolUse removed - causes API 400 errors)
|
|
1386
|
+
|
|
1387
|
+
# Define all hooks - must match ACTIVE_HOOK_TYPES
|
|
1388
|
+
# Format: (hook_type, matcher, command, timeout)
|
|
1279
1389
|
hook_configs = [
|
|
1280
1390
|
('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
|
|
1281
1391
|
('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
|
|
1282
1392
|
('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
|
|
1283
|
-
|
|
1284
|
-
('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
|
|
1393
|
+
('Stop', '', f'{hook_cmd_base} poll', 86400), # 24hr timeout max; internal timeout 30min default via config
|
|
1285
1394
|
('Notification', '', f'{hook_cmd_base} notify', None),
|
|
1395
|
+
('SessionEnd', '', f'{hook_cmd_base} sessionend', None),
|
|
1286
1396
|
]
|
|
1287
|
-
|
|
1397
|
+
|
|
1398
|
+
# Validate hook_configs matches ACTIVE_HOOK_TYPES
|
|
1399
|
+
configured_types = [hook_type for hook_type, _, _, _ in hook_configs]
|
|
1400
|
+
if configured_types != ACTIVE_HOOK_TYPES:
|
|
1401
|
+
raise Exception(format_error(
|
|
1402
|
+
f"Hook configuration mismatch: {configured_types} != {ACTIVE_HOOK_TYPES}"
|
|
1403
|
+
))
|
|
1404
|
+
|
|
1288
1405
|
for hook_type, matcher, command, timeout in hook_configs:
|
|
1289
1406
|
if hook_type not in settings['hooks']:
|
|
1290
1407
|
settings['hooks'][hook_type] = []
|
|
1291
|
-
|
|
1408
|
+
|
|
1292
1409
|
hook_dict = {
|
|
1293
1410
|
'matcher': matcher,
|
|
1294
1411
|
'hooks': [{
|
|
@@ -1298,9 +1415,17 @@ def setup_hooks():
|
|
|
1298
1415
|
}
|
|
1299
1416
|
if timeout is not None:
|
|
1300
1417
|
hook_dict['hooks'][0]['timeout'] = timeout
|
|
1301
|
-
|
|
1418
|
+
|
|
1302
1419
|
settings['hooks'][hook_type].append(hook_dict)
|
|
1303
|
-
|
|
1420
|
+
|
|
1421
|
+
# Set $HCOM environment variable for all Claude instances (vanilla + hcom-launched)
|
|
1422
|
+
if 'env' not in settings:
|
|
1423
|
+
settings['env'] = {}
|
|
1424
|
+
|
|
1425
|
+
python_path = sys.executable
|
|
1426
|
+
script_path = str(Path(__file__).resolve())
|
|
1427
|
+
settings['env']['HCOM'] = f'{python_path} {script_path}'
|
|
1428
|
+
|
|
1304
1429
|
# Write settings atomically
|
|
1305
1430
|
try:
|
|
1306
1431
|
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
@@ -1314,7 +1439,7 @@ def setup_hooks():
|
|
|
1314
1439
|
return True
|
|
1315
1440
|
|
|
1316
1441
|
def verify_hooks_installed(settings_path):
|
|
1317
|
-
"""Verify that HCOM hooks were installed correctly"""
|
|
1442
|
+
"""Verify that HCOM hooks were installed correctly with correct commands"""
|
|
1318
1443
|
try:
|
|
1319
1444
|
settings = read_file_with_retry(
|
|
1320
1445
|
settings_path,
|
|
@@ -1324,13 +1449,33 @@ def verify_hooks_installed(settings_path):
|
|
|
1324
1449
|
if not settings:
|
|
1325
1450
|
return False
|
|
1326
1451
|
|
|
1327
|
-
# Check all hook types
|
|
1452
|
+
# Check all hook types have correct commands
|
|
1328
1453
|
hooks = settings.get('hooks', {})
|
|
1329
|
-
for hook_type in
|
|
1330
|
-
|
|
1331
|
-
|
|
1454
|
+
for hook_type, expected_cmd in zip(ACTIVE_HOOK_TYPES, HOOK_COMMANDS):
|
|
1455
|
+
hook_matchers = hooks.get(hook_type, [])
|
|
1456
|
+
if not hook_matchers:
|
|
1457
|
+
return False
|
|
1458
|
+
|
|
1459
|
+
# Check if any matcher has the correct command
|
|
1460
|
+
found_correct_cmd = False
|
|
1461
|
+
for matcher in hook_matchers:
|
|
1462
|
+
for hook in matcher.get('hooks', []):
|
|
1463
|
+
command = hook.get('command', '')
|
|
1464
|
+
# Check for HCOM and the correct subcommand
|
|
1465
|
+
if ('${HCOM}' in command or 'hcom' in command.lower()) and expected_cmd in command:
|
|
1466
|
+
found_correct_cmd = True
|
|
1467
|
+
break
|
|
1468
|
+
if found_correct_cmd:
|
|
1469
|
+
break
|
|
1470
|
+
|
|
1471
|
+
if not found_correct_cmd:
|
|
1332
1472
|
return False
|
|
1333
1473
|
|
|
1474
|
+
# Check that HCOM env var is set
|
|
1475
|
+
env = settings.get('env', {})
|
|
1476
|
+
if 'HCOM' not in env:
|
|
1477
|
+
return False
|
|
1478
|
+
|
|
1334
1479
|
return True
|
|
1335
1480
|
except Exception:
|
|
1336
1481
|
return False
|
|
@@ -1343,74 +1488,6 @@ def get_archive_timestamp():
|
|
|
1343
1488
|
"""Get timestamp for archive files"""
|
|
1344
1489
|
return datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
1345
1490
|
|
|
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
1491
|
class LogParseResult(NamedTuple):
|
|
1415
1492
|
"""Result from parsing log messages"""
|
|
1416
1493
|
messages: list[dict[str, str]]
|
|
@@ -1465,7 +1542,7 @@ def get_unread_messages(instance_name: str, update_position: bool = False) -> li
|
|
|
1465
1542
|
instance_name: Name of instance to get messages for
|
|
1466
1543
|
update_position: If True, mark messages as read by updating position
|
|
1467
1544
|
"""
|
|
1468
|
-
log_file = hcom_path(LOG_FILE
|
|
1545
|
+
log_file = hcom_path(LOG_FILE)
|
|
1469
1546
|
|
|
1470
1547
|
if not log_file.exists():
|
|
1471
1548
|
return []
|
|
@@ -1507,33 +1584,44 @@ def format_age(seconds: float) -> str:
|
|
|
1507
1584
|
else:
|
|
1508
1585
|
return f"{int(seconds/3600)}h"
|
|
1509
1586
|
|
|
1510
|
-
def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str]:
|
|
1511
|
-
"""Get current status of instance. Returns (status_type, age_string)."""
|
|
1512
|
-
# Returns: (display_category, formatted_age
|
|
1587
|
+
def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
|
|
1588
|
+
"""Get current status of instance. Returns (status_type, age_string, description)."""
|
|
1589
|
+
# Returns: (display_category, formatted_age, status_description)
|
|
1513
1590
|
now = int(time.time())
|
|
1514
1591
|
|
|
1515
|
-
# Check if killed
|
|
1516
|
-
if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
|
|
1517
|
-
return "inactive", ""
|
|
1518
|
-
|
|
1519
1592
|
# Get last known status
|
|
1520
1593
|
last_status = pos_data.get('last_status', '')
|
|
1521
1594
|
last_status_time = pos_data.get('last_status_time', 0)
|
|
1595
|
+
last_context = pos_data.get('last_status_context', '')
|
|
1522
1596
|
|
|
1523
1597
|
if not last_status or not last_status_time:
|
|
1524
|
-
return "unknown", ""
|
|
1598
|
+
return "unknown", "", "unknown"
|
|
1525
1599
|
|
|
1526
|
-
# Get display category from STATUS_INFO
|
|
1527
|
-
display_status,
|
|
1600
|
+
# Get display category and description template from STATUS_INFO
|
|
1601
|
+
display_status, desc_template = STATUS_INFO.get(last_status, ('unknown', 'unknown'))
|
|
1528
1602
|
|
|
1529
1603
|
# Check timeout
|
|
1530
1604
|
age = now - last_status_time
|
|
1531
1605
|
timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
|
|
1532
1606
|
if age > timeout:
|
|
1533
|
-
return "inactive", ""
|
|
1607
|
+
return "inactive", "", "timeout"
|
|
1608
|
+
|
|
1609
|
+
# Detect stale 'waiting' status - check heartbeat, not status timestamp
|
|
1610
|
+
if last_status == 'waiting':
|
|
1611
|
+
last_stop = pos_data.get('last_stop', 0)
|
|
1612
|
+
heartbeat_age = now - last_stop if last_stop else 999999
|
|
1613
|
+
if heartbeat_age > 2:
|
|
1614
|
+
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1615
|
+
return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
|
|
1616
|
+
|
|
1617
|
+
# Format description with context if template has {}
|
|
1618
|
+
if '{}' in desc_template and last_context:
|
|
1619
|
+
status_desc = desc_template.format(last_context)
|
|
1620
|
+
else:
|
|
1621
|
+
status_desc = desc_template
|
|
1534
1622
|
|
|
1535
1623
|
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1536
|
-
return display_status, f"({format_age(age)}){status_suffix}"
|
|
1624
|
+
return display_status, f"({format_age(age)}){status_suffix}", status_desc
|
|
1537
1625
|
|
|
1538
1626
|
def get_status_block(status_type: str) -> str:
|
|
1539
1627
|
"""Get colored status block for a status type"""
|
|
@@ -1589,44 +1677,48 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1589
1677
|
messages = parse_log_messages(log_file).messages
|
|
1590
1678
|
show_recent_messages(messages, limit, truncate=True)
|
|
1591
1679
|
|
|
1680
|
+
def should_show_in_watch(d):
|
|
1681
|
+
"""Show only enabled instances by default"""
|
|
1682
|
+
# Hide disabled instances
|
|
1683
|
+
if not d.get('enabled', False):
|
|
1684
|
+
return False
|
|
1685
|
+
|
|
1686
|
+
# Hide truly ended sessions
|
|
1687
|
+
if d.get('session_ended'):
|
|
1688
|
+
return False
|
|
1689
|
+
|
|
1690
|
+
# Show all other instances (including 'closed' during transition)
|
|
1691
|
+
return True
|
|
1692
|
+
|
|
1592
1693
|
def show_instances_by_directory():
|
|
1593
1694
|
"""Show instances organized by their working directories"""
|
|
1594
1695
|
positions = load_all_positions()
|
|
1595
1696
|
if not positions:
|
|
1596
1697
|
print(f" {DIM}No Claude instances connected{RESET}")
|
|
1597
1698
|
return
|
|
1598
|
-
|
|
1699
|
+
|
|
1599
1700
|
if positions:
|
|
1600
1701
|
directories = {}
|
|
1601
1702
|
for instance_name, pos_data in positions.items():
|
|
1703
|
+
if not should_show_in_watch(pos_data):
|
|
1704
|
+
continue
|
|
1602
1705
|
directory = pos_data.get("directory", "unknown")
|
|
1603
1706
|
if directory not in directories:
|
|
1604
1707
|
directories[directory] = []
|
|
1605
1708
|
directories[directory].append((instance_name, pos_data))
|
|
1606
|
-
|
|
1709
|
+
|
|
1607
1710
|
for directory, instances in directories.items():
|
|
1608
1711
|
print(f" {directory}")
|
|
1609
1712
|
for instance_name, pos_data in instances:
|
|
1610
|
-
status_type, age = get_instance_status(pos_data)
|
|
1713
|
+
status_type, age, status_desc = get_instance_status(pos_data)
|
|
1611
1714
|
status_block = get_status_block(status_type)
|
|
1612
1715
|
|
|
1613
|
-
# Format status description using STATUS_INFO and context
|
|
1614
|
-
last_status = pos_data.get('last_status', '')
|
|
1615
|
-
last_context = pos_data.get('last_status_context', '')
|
|
1616
|
-
_, desc_template = STATUS_INFO.get(last_status, ('unknown', ''))
|
|
1617
|
-
|
|
1618
|
-
# Format description with context if template has {}
|
|
1619
|
-
if '{}' in desc_template and last_context:
|
|
1620
|
-
status_desc = desc_template.format(last_context)
|
|
1621
|
-
else:
|
|
1622
|
-
status_desc = desc_template
|
|
1623
|
-
|
|
1624
1716
|
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_desc} {age}{RESET}")
|
|
1625
1717
|
print()
|
|
1626
1718
|
else:
|
|
1627
1719
|
print(f" {DIM}Error reading instance data{RESET}")
|
|
1628
1720
|
|
|
1629
|
-
def alt_screen_detailed_status_and_input():
|
|
1721
|
+
def alt_screen_detailed_status_and_input() -> str:
|
|
1630
1722
|
"""Show detailed status in alt screen and get user input"""
|
|
1631
1723
|
sys.stdout.write("\033[?1049h\033[2J\033[H")
|
|
1632
1724
|
|
|
@@ -1664,7 +1756,10 @@ def get_status_summary():
|
|
|
1664
1756
|
status_counts = {status: 0 for status in STATUS_MAP.keys()}
|
|
1665
1757
|
|
|
1666
1758
|
for _, pos_data in positions.items():
|
|
1667
|
-
|
|
1759
|
+
# Only count instances that should be shown in watch
|
|
1760
|
+
if not should_show_in_watch(pos_data):
|
|
1761
|
+
continue
|
|
1762
|
+
status_type, _, _ = get_instance_status(pos_data)
|
|
1668
1763
|
if status_type in status_counts:
|
|
1669
1764
|
status_counts[status_type] += 1
|
|
1670
1765
|
|
|
@@ -1701,8 +1796,12 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1701
1796
|
try:
|
|
1702
1797
|
data = load_instance_position(instance_name)
|
|
1703
1798
|
|
|
1799
|
+
# Determine default enabled state: True for hcom-launched, False for vanilla
|
|
1800
|
+
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
1801
|
+
|
|
1704
1802
|
defaults = {
|
|
1705
1803
|
"pos": 0,
|
|
1804
|
+
"enabled": is_hcom_launched,
|
|
1706
1805
|
"directory": str(Path.cwd()),
|
|
1707
1806
|
"last_stop": 0,
|
|
1708
1807
|
"session_id": session_id or "",
|
|
@@ -1738,6 +1837,26 @@ def update_instance_position(instance_name, update_fields):
|
|
|
1738
1837
|
else:
|
|
1739
1838
|
raise
|
|
1740
1839
|
|
|
1840
|
+
def enable_instance(instance_name):
|
|
1841
|
+
"""Enable instance - clears all stop flags and enables Stop hook polling"""
|
|
1842
|
+
update_instance_position(instance_name, {
|
|
1843
|
+
'enabled': True,
|
|
1844
|
+
'force_closed': False,
|
|
1845
|
+
'session_ended': False
|
|
1846
|
+
})
|
|
1847
|
+
set_status(instance_name, 'started')
|
|
1848
|
+
|
|
1849
|
+
def disable_instance(instance_name, force=False):
|
|
1850
|
+
"""Disable instance - stops Stop hook polling"""
|
|
1851
|
+
updates = {
|
|
1852
|
+
'enabled': False
|
|
1853
|
+
}
|
|
1854
|
+
if force:
|
|
1855
|
+
updates['force_closed'] = True
|
|
1856
|
+
|
|
1857
|
+
update_instance_position(instance_name, updates)
|
|
1858
|
+
set_status(instance_name, 'force_stopped' if force else 'stopped')
|
|
1859
|
+
|
|
1741
1860
|
def set_status(instance_name: str, status: str, context: str = ''):
|
|
1742
1861
|
"""Set instance status event with timestamp"""
|
|
1743
1862
|
update_instance_position(instance_name, {
|
|
@@ -1745,7 +1864,6 @@ def set_status(instance_name: str, status: str, context: str = ''):
|
|
|
1745
1864
|
'last_status_time': int(time.time()),
|
|
1746
1865
|
'last_status_context': context
|
|
1747
1866
|
})
|
|
1748
|
-
log_hook_error(f"Set status for {instance_name} to {status} with context {context}") #TODO: change to 'log'?
|
|
1749
1867
|
|
|
1750
1868
|
def merge_instance_data(to_data, from_data):
|
|
1751
1869
|
"""Merge instance data from from_data into to_data."""
|
|
@@ -1753,7 +1871,6 @@ def merge_instance_data(to_data, from_data):
|
|
|
1753
1871
|
to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
|
|
1754
1872
|
|
|
1755
1873
|
# Update transient fields from source
|
|
1756
|
-
to_data['pid'] = os.getppid() # Always use current PID
|
|
1757
1874
|
to_data['transcript_path'] = from_data.get('transcript_path', to_data.get('transcript_path', ''))
|
|
1758
1875
|
|
|
1759
1876
|
# Preserve maximum position
|
|
@@ -1780,20 +1897,6 @@ def merge_instance_data(to_data, from_data):
|
|
|
1780
1897
|
|
|
1781
1898
|
return to_data
|
|
1782
1899
|
|
|
1783
|
-
def terminate_process(pid, force=False):
|
|
1784
|
-
"""Cross-platform process termination"""
|
|
1785
|
-
try:
|
|
1786
|
-
if IS_WINDOWS:
|
|
1787
|
-
cmd = ['taskkill', '/PID', str(pid)]
|
|
1788
|
-
if force:
|
|
1789
|
-
cmd.insert(1, '/F')
|
|
1790
|
-
subprocess.run(cmd, capture_output=True, check=True)
|
|
1791
|
-
else:
|
|
1792
|
-
os.kill(pid, 9 if force else 15) # SIGKILL or SIGTERM
|
|
1793
|
-
return True
|
|
1794
|
-
except (ProcessLookupError, OSError, subprocess.CalledProcessError):
|
|
1795
|
-
return False # Process already dead
|
|
1796
|
-
|
|
1797
1900
|
def merge_instance_immediately(from_name, to_name):
|
|
1798
1901
|
"""Merge from_name into to_name with safety checks. Returns success message or error message."""
|
|
1799
1902
|
if from_name == to_name:
|
|
@@ -1856,33 +1959,176 @@ def show_cli_hints(to_stderr=True):
|
|
|
1856
1959
|
else:
|
|
1857
1960
|
print(f"\n{cli_hints}")
|
|
1858
1961
|
|
|
1962
|
+
# ==================== CLI Parsing Functions ====================
|
|
1963
|
+
|
|
1964
|
+
def parse_count(value: str) -> int:
|
|
1965
|
+
"""Parse and validate instance count"""
|
|
1966
|
+
try:
|
|
1967
|
+
number = int(value, 10)
|
|
1968
|
+
except ValueError as exc:
|
|
1969
|
+
raise argparse.ArgumentTypeError('Count must be an integer. Use -a/--agent for agent names.') from exc
|
|
1970
|
+
if number <= 0:
|
|
1971
|
+
raise argparse.ArgumentTypeError('Count must be positive.')
|
|
1972
|
+
if number > 100:
|
|
1973
|
+
raise argparse.ArgumentTypeError('Too many instances requested (max 100).')
|
|
1974
|
+
return number
|
|
1975
|
+
|
|
1976
|
+
def split_forwarded_args(argv: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
1977
|
+
"""Split arguments on -- separator for forwarding to claude"""
|
|
1978
|
+
if '--' not in argv:
|
|
1979
|
+
return list(argv), []
|
|
1980
|
+
idx = argv.index('--')
|
|
1981
|
+
return list(argv[:idx]), list(argv[idx + 1:])
|
|
1982
|
+
|
|
1983
|
+
def parse_open(namespace: argparse.Namespace, forwarded: list[str]) -> OpenCommand:
|
|
1984
|
+
"""Parse and validate open command arguments"""
|
|
1985
|
+
prefix = namespace.prefix
|
|
1986
|
+
if prefix and '|' in prefix:
|
|
1987
|
+
raise CLIError('Prefix cannot contain "|" characters.')
|
|
1988
|
+
|
|
1989
|
+
agents = namespace.agent or []
|
|
1990
|
+
count = namespace.count if namespace.count is not None else 1
|
|
1991
|
+
if not agents:
|
|
1992
|
+
agents = ['generic']
|
|
1993
|
+
|
|
1994
|
+
return OpenCommand(
|
|
1995
|
+
count=count,
|
|
1996
|
+
agents=agents,
|
|
1997
|
+
prefix=prefix,
|
|
1998
|
+
background=namespace.background,
|
|
1999
|
+
claude_args=forwarded,
|
|
2000
|
+
)
|
|
2001
|
+
|
|
2002
|
+
def parse_watch(namespace: argparse.Namespace) -> WatchCommand:
|
|
2003
|
+
"""Parse and validate watch command arguments"""
|
|
2004
|
+
wait_value = namespace.wait
|
|
2005
|
+
if wait_value is not None and wait_value < 0:
|
|
2006
|
+
raise CLIError('--wait expects a non-negative number of seconds.')
|
|
2007
|
+
|
|
2008
|
+
if wait_value is not None:
|
|
2009
|
+
return WatchCommand(mode='wait', wait_seconds=wait_value or 60)
|
|
2010
|
+
if namespace.logs:
|
|
2011
|
+
return WatchCommand(mode='logs', wait_seconds=None)
|
|
2012
|
+
if namespace.status:
|
|
2013
|
+
return WatchCommand(mode='status', wait_seconds=None)
|
|
2014
|
+
return WatchCommand(mode='interactive', wait_seconds=None)
|
|
2015
|
+
|
|
2016
|
+
def parse_stop(namespace: argparse.Namespace) -> StopCommand:
|
|
2017
|
+
"""Parse and validate stop command arguments"""
|
|
2018
|
+
target = namespace.target
|
|
2019
|
+
return StopCommand(
|
|
2020
|
+
target=target,
|
|
2021
|
+
close_all_hooks=namespace.all,
|
|
2022
|
+
force=namespace.force,
|
|
2023
|
+
_hcom_session=getattr(namespace, '_hcom_session', None),
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
def parse_start(namespace: argparse.Namespace) -> StartCommand:
|
|
2027
|
+
"""Parse and validate start command arguments"""
|
|
2028
|
+
return StartCommand(
|
|
2029
|
+
target=namespace.target,
|
|
2030
|
+
_hcom_session=getattr(namespace, '_hcom_session', None),
|
|
2031
|
+
)
|
|
2032
|
+
|
|
2033
|
+
def parse_send(namespace: argparse.Namespace) -> SendCommand:
|
|
2034
|
+
"""Parse and validate send command arguments"""
|
|
2035
|
+
if namespace.resume and namespace.message:
|
|
2036
|
+
raise CLIError('Specify a resume alias or a message, not both.')
|
|
2037
|
+
session_id = getattr(namespace, '_hcom_session', None)
|
|
2038
|
+
if namespace.resume:
|
|
2039
|
+
return SendCommand(message=None, resume_alias=namespace.resume, _hcom_session=session_id)
|
|
2040
|
+
if namespace.message is None:
|
|
2041
|
+
raise CLIError('Message required (usage: hcom send "message").')
|
|
2042
|
+
return SendCommand(message=namespace.message, resume_alias=None, _hcom_session=session_id)
|
|
2043
|
+
|
|
2044
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2045
|
+
"""Build argparse parser for hcom commands"""
|
|
2046
|
+
parser = argparse.ArgumentParser(prog='hcom', add_help=False)
|
|
2047
|
+
subparsers = parser.add_subparsers(dest='command', required=True)
|
|
2048
|
+
|
|
2049
|
+
# Open command
|
|
2050
|
+
open_parser = subparsers.add_parser('open', add_help=False)
|
|
2051
|
+
open_parser.add_argument('count', nargs='?', type=parse_count, default=1)
|
|
2052
|
+
open_parser.add_argument('-a', '--agent', dest='agent', action='append')
|
|
2053
|
+
open_parser.add_argument('-t', '--prefix', dest='prefix')
|
|
2054
|
+
open_parser.add_argument('-p', '--background', action='store_true', dest='background')
|
|
2055
|
+
open_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2056
|
+
open_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2057
|
+
|
|
2058
|
+
# Watch command
|
|
2059
|
+
watch_parser = subparsers.add_parser('watch', add_help=False)
|
|
2060
|
+
group = watch_parser.add_mutually_exclusive_group()
|
|
2061
|
+
group.add_argument('--logs', action='store_true')
|
|
2062
|
+
group.add_argument('--status', action='store_true')
|
|
2063
|
+
group.add_argument('--wait', nargs='?', const=60, type=int, metavar='SEC')
|
|
2064
|
+
watch_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2065
|
+
watch_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2066
|
+
|
|
2067
|
+
# Stop command
|
|
2068
|
+
stop_parser = subparsers.add_parser('stop', add_help=False)
|
|
2069
|
+
stop_parser.add_argument('target', nargs='?')
|
|
2070
|
+
stop_parser.add_argument('--all', action='store_true')
|
|
2071
|
+
stop_parser.add_argument('--force', action='store_true')
|
|
2072
|
+
stop_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
|
|
2073
|
+
stop_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2074
|
+
stop_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2075
|
+
|
|
2076
|
+
# Start command
|
|
2077
|
+
start_parser = subparsers.add_parser('start', add_help=False)
|
|
2078
|
+
start_parser.add_argument('target', nargs='?')
|
|
2079
|
+
start_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
|
|
2080
|
+
start_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2081
|
+
start_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2082
|
+
|
|
2083
|
+
# Send command
|
|
2084
|
+
send_parser = subparsers.add_parser('send', add_help=False)
|
|
2085
|
+
send_parser.add_argument('message', nargs='?')
|
|
2086
|
+
send_parser.add_argument('--resume', metavar='ALIAS', help=argparse.SUPPRESS)
|
|
2087
|
+
send_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
|
|
2088
|
+
send_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2089
|
+
send_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2090
|
+
|
|
2091
|
+
return parser
|
|
2092
|
+
|
|
2093
|
+
def dispatch(namespace: argparse.Namespace, forwarded: list[str]):
|
|
2094
|
+
"""Dispatch parsed arguments to appropriate command parser"""
|
|
2095
|
+
command = namespace.command
|
|
2096
|
+
if command == 'open':
|
|
2097
|
+
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2098
|
+
return cmd_help()
|
|
2099
|
+
return parse_open(namespace, forwarded)
|
|
2100
|
+
if command == 'watch':
|
|
2101
|
+
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2102
|
+
return cmd_help()
|
|
2103
|
+
return parse_watch(namespace)
|
|
2104
|
+
if command == 'stop':
|
|
2105
|
+
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2106
|
+
return cmd_help()
|
|
2107
|
+
return parse_stop(namespace)
|
|
2108
|
+
if command == 'start':
|
|
2109
|
+
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2110
|
+
return cmd_help()
|
|
2111
|
+
return parse_start(namespace)
|
|
2112
|
+
if command == 'send':
|
|
2113
|
+
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2114
|
+
return cmd_help()
|
|
2115
|
+
return parse_send(namespace)
|
|
2116
|
+
raise CLIError(f'Unsupported command: {command}')
|
|
2117
|
+
|
|
2118
|
+
def needs_help(args: Sequence[str]) -> bool:
|
|
2119
|
+
"""Check if help was requested"""
|
|
2120
|
+
if not args:
|
|
2121
|
+
return True
|
|
2122
|
+
head = args[0]
|
|
2123
|
+
if head in {'help', '--help', '-h'}:
|
|
2124
|
+
return True
|
|
2125
|
+
return False
|
|
2126
|
+
|
|
2127
|
+
# ==================== Command Functions ====================
|
|
2128
|
+
|
|
1859
2129
|
def cmd_help():
|
|
1860
2130
|
"""Show help text"""
|
|
1861
|
-
|
|
1862
|
-
print("""hcom - Claude Hook Comms
|
|
1863
|
-
|
|
1864
|
-
Usage:
|
|
1865
|
-
hcom open [n] Launch n Claude instances
|
|
1866
|
-
hcom open <agent> Launch named agent from .claude/agents/
|
|
1867
|
-
hcom open --prefix <team> n Launch n instances with team prefix
|
|
1868
|
-
hcom open --background Launch instances as background processes (-p also works)
|
|
1869
|
-
hcom open --claude-args "--model sonnet" Pass claude code CLI flags
|
|
1870
|
-
hcom watch View conversation dashboard
|
|
1871
|
-
hcom clear Clear and archive conversation
|
|
1872
|
-
hcom cleanup Remove hooks from current directory
|
|
1873
|
-
hcom cleanup --all Remove hooks from all tracked directories
|
|
1874
|
-
hcom kill [instance alias] Kill specific instance
|
|
1875
|
-
hcom kill --all Kill all running instances
|
|
1876
|
-
hcom help Show this help
|
|
1877
|
-
|
|
1878
|
-
Automation:
|
|
1879
|
-
hcom send 'msg' Send message to all
|
|
1880
|
-
hcom send '@prefix msg' Send to specific instances
|
|
1881
|
-
hcom watch --logs Show conversation log
|
|
1882
|
-
hcom watch --status Show status of instances
|
|
1883
|
-
hcom watch --wait [seconds] Wait for new messages (default 60s)
|
|
1884
|
-
|
|
1885
|
-
Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
|
|
2131
|
+
print(HELP_TEXT)
|
|
1886
2132
|
|
|
1887
2133
|
# Additional help for AI assistants
|
|
1888
2134
|
if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
|
|
@@ -1892,26 +2138,27 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
|
|
|
1892
2138
|
|
|
1893
2139
|
CONCEPT: HCOM launches Claude Code instances in new terminal windows.
|
|
1894
2140
|
They communicate with each other via a shared conversation.
|
|
1895
|
-
You communicate with them via hcom
|
|
2141
|
+
You communicate with them via hcom commands.
|
|
1896
2142
|
|
|
1897
2143
|
KEY UNDERSTANDING:
|
|
1898
2144
|
• Single conversation - All instances share ~/.hcom/hcom.log
|
|
1899
|
-
• Messaging -
|
|
1900
|
-
• Instances receive messages via hooks automatically
|
|
2145
|
+
• Messaging - CLI and instances send with hcom send "message"
|
|
2146
|
+
• Instances receive messages via hooks automatically
|
|
1901
2147
|
• hcom open is directory-specific - always cd to project directory first
|
|
1902
|
-
•
|
|
1903
|
-
|
|
1904
|
-
•
|
|
1905
|
-
"reviewer" named agent loads .claude/agents/reviewer.md (if it was ever created)
|
|
2148
|
+
• Named agents are custom system prompt files created by users/claude code beforehand.
|
|
2149
|
+
• Named agents load from .claude/agents/<name>.md - if they have been created
|
|
2150
|
+
• hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
|
|
1906
2151
|
|
|
1907
2152
|
LAUNCH PATTERNS:
|
|
1908
|
-
hcom open 2
|
|
1909
|
-
hcom open
|
|
1910
|
-
hcom open
|
|
1911
|
-
hcom open
|
|
1912
|
-
hcom open
|
|
1913
|
-
hcom
|
|
1914
|
-
|
|
2153
|
+
hcom open 2 # 2 generic instances
|
|
2154
|
+
hcom open -a reviewer # 1 reviewer instance (agent file must already exist)
|
|
2155
|
+
hcom open 3 -a reviewer # 3 reviewer instances
|
|
2156
|
+
hcom open -a reviewer -a tester # 1 reviewer + 1 tester
|
|
2157
|
+
hcom open -t api 2 # Team naming: api-hova7, api-kolec
|
|
2158
|
+
hcom open -- --model sonnet # Pass `claude` CLI flags after --
|
|
2159
|
+
hcom open -p # Detached background (stop with: hcom stop <alias>)
|
|
2160
|
+
hcom open -- --resume <sessionid> # Resume specific session
|
|
2161
|
+
HCOM_INITIAL_PROMPT="task" hcom open # Set initial prompt for instance
|
|
1915
2162
|
|
|
1916
2163
|
@MENTION TARGETING:
|
|
1917
2164
|
hcom send "message" # Broadcasts to everyone
|
|
@@ -1920,12 +2167,9 @@ LAUNCH PATTERNS:
|
|
|
1920
2167
|
(Unmatched @mentions broadcast to everyone)
|
|
1921
2168
|
|
|
1922
2169
|
STATUS INDICATORS:
|
|
1923
|
-
• ▶ active - instance
|
|
1924
|
-
•
|
|
1925
|
-
•
|
|
1926
|
-
• ■ blocked - instance is blocked by permission request (needs user approval)
|
|
1927
|
-
• ○ inactive - instance is timed out, disconnected, etc
|
|
1928
|
-
• ○ unknown - no status information available
|
|
2170
|
+
• ▶ active - processing/executing • ▷ delivered - instance just received a message
|
|
2171
|
+
• ◉ idle - waiting for new messages • ■ blocked - permission request (needs user approval)
|
|
2172
|
+
• ○ inactive - timed out, disconnected, etc • ○ unknown
|
|
1929
2173
|
|
|
1930
2174
|
CONFIG:
|
|
1931
2175
|
Config file (persistent): ~/.hcom/config.json
|
|
@@ -1952,112 +2196,114 @@ Run 'claude --help' to see all claude code CLI flags.""")
|
|
|
1952
2196
|
|
|
1953
2197
|
return 0
|
|
1954
2198
|
|
|
1955
|
-
def cmd_open(
|
|
2199
|
+
def cmd_open(command: OpenCommand):
|
|
1956
2200
|
"""Launch Claude instances with chat enabled"""
|
|
1957
2201
|
try:
|
|
1958
|
-
# Parse arguments
|
|
1959
|
-
instances, prefix, claude_args, background = parse_open_args(list(args))
|
|
1960
|
-
|
|
1961
2202
|
# Add -p flag and stream-json output for background mode if not already present
|
|
1962
|
-
|
|
2203
|
+
claude_args = command.claude_args
|
|
2204
|
+
if command.background and '-p' not in claude_args and '--print' not in claude_args:
|
|
1963
2205
|
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
1964
|
-
|
|
2206
|
+
|
|
1965
2207
|
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
1966
|
-
|
|
2208
|
+
|
|
2209
|
+
# Calculate total instances to launch
|
|
2210
|
+
total_instances = command.count * len(command.agents)
|
|
2211
|
+
|
|
1967
2212
|
# Fail fast for same_terminal with multiple instances
|
|
1968
|
-
if terminal_mode == 'same_terminal' and
|
|
2213
|
+
if terminal_mode == 'same_terminal' and total_instances > 1:
|
|
1969
2214
|
print(format_error(
|
|
1970
|
-
f"same_terminal mode cannot launch {
|
|
1971
|
-
"Use 'hcom open' for one generic instance or 'hcom open <agent>' for one agent"
|
|
2215
|
+
f"same_terminal mode cannot launch {total_instances} instances",
|
|
2216
|
+
"Use 'hcom open' for one generic instance or 'hcom open -a <agent>' for one agent"
|
|
1972
2217
|
), file=sys.stderr)
|
|
1973
2218
|
return 1
|
|
1974
|
-
|
|
2219
|
+
|
|
1975
2220
|
try:
|
|
1976
2221
|
setup_hooks()
|
|
1977
2222
|
except Exception as e:
|
|
1978
2223
|
print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
|
|
1979
2224
|
return 1
|
|
1980
|
-
|
|
1981
|
-
log_file = hcom_path(LOG_FILE
|
|
2225
|
+
|
|
2226
|
+
log_file = hcom_path(LOG_FILE)
|
|
1982
2227
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1983
|
-
|
|
1984
|
-
|
|
2228
|
+
|
|
1985
2229
|
if not log_file.exists():
|
|
1986
2230
|
log_file.touch()
|
|
1987
|
-
|
|
2231
|
+
|
|
1988
2232
|
# Build environment variables for Claude instances
|
|
1989
2233
|
base_env = build_claude_env()
|
|
1990
2234
|
|
|
1991
2235
|
# Add prefix-specific hints if provided
|
|
1992
|
-
if prefix:
|
|
1993
|
-
base_env['HCOM_PREFIX'] = prefix
|
|
2236
|
+
if command.prefix:
|
|
2237
|
+
base_env['HCOM_PREFIX'] = command.prefix
|
|
1994
2238
|
send_cmd = build_send_command()
|
|
1995
|
-
hint = f"To respond to {prefix} group: {send_cmd} '@{prefix} message'"
|
|
2239
|
+
hint = f"To respond to {command.prefix} group: {send_cmd} '@{command.prefix} message'"
|
|
1996
2240
|
base_env['HCOM_INSTANCE_HINTS'] = hint
|
|
1997
|
-
first_use = f"You're in the {prefix} group. Use {prefix} to message: {send_cmd} '@{prefix} message'"
|
|
2241
|
+
first_use = f"You're in the {command.prefix} group. Use {command.prefix} to message: {send_cmd} '@{command.prefix} message'"
|
|
1998
2242
|
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
1999
|
-
|
|
2243
|
+
|
|
2000
2244
|
launched = 0
|
|
2001
2245
|
initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
#
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
#
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
claude_args=claude_args,
|
|
2022
|
-
initial_prompt=initial_prompt
|
|
2023
|
-
)
|
|
2024
|
-
else:
|
|
2025
|
-
# Agent instance
|
|
2026
|
-
try:
|
|
2027
|
-
agent_content, agent_config = resolve_agent(instance_type)
|
|
2028
|
-
# Mark this as a subagent instance for SessionStart hook
|
|
2029
|
-
instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
|
|
2030
|
-
# Prepend agent instance awareness to system prompt
|
|
2031
|
-
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
2032
|
-
agent_content = agent_prefix + agent_content
|
|
2033
|
-
# Use agent's model and tools if specified and not overridden in claude_args
|
|
2034
|
-
agent_model = agent_config.get('model')
|
|
2035
|
-
agent_tools = agent_config.get('tools')
|
|
2246
|
+
|
|
2247
|
+
# Launch count instances of each agent
|
|
2248
|
+
for agent in command.agents:
|
|
2249
|
+
for _ in range(command.count):
|
|
2250
|
+
instance_type = agent
|
|
2251
|
+
instance_env = base_env.copy()
|
|
2252
|
+
|
|
2253
|
+
# Mark all hcom-launched instances
|
|
2254
|
+
instance_env['HCOM_LAUNCHED'] = '1'
|
|
2255
|
+
|
|
2256
|
+
# Mark background instances via environment with log filename
|
|
2257
|
+
if command.background:
|
|
2258
|
+
# Generate unique log filename
|
|
2259
|
+
log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
|
|
2260
|
+
instance_env['HCOM_BACKGROUND'] = log_filename
|
|
2261
|
+
|
|
2262
|
+
# Build claude command
|
|
2263
|
+
if instance_type == 'generic':
|
|
2264
|
+
# Generic instance - no agent content
|
|
2036
2265
|
claude_cmd, _ = build_claude_command(
|
|
2037
|
-
agent_content=
|
|
2266
|
+
agent_content=None,
|
|
2038
2267
|
claude_args=claude_args,
|
|
2039
|
-
initial_prompt=initial_prompt
|
|
2040
|
-
model=agent_model,
|
|
2041
|
-
tools=agent_tools
|
|
2268
|
+
initial_prompt=initial_prompt
|
|
2042
2269
|
)
|
|
2043
|
-
# Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
|
|
2044
|
-
except (FileNotFoundError, ValueError) as e:
|
|
2045
|
-
print(str(e), file=sys.stderr)
|
|
2046
|
-
continue
|
|
2047
|
-
|
|
2048
|
-
try:
|
|
2049
|
-
if background:
|
|
2050
|
-
log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
|
|
2051
|
-
if log_file:
|
|
2052
|
-
print(f"Background instance launched, log: {log_file}")
|
|
2053
|
-
launched += 1
|
|
2054
2270
|
else:
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2271
|
+
# Agent instance
|
|
2272
|
+
try:
|
|
2273
|
+
agent_content, agent_config = resolve_agent(instance_type)
|
|
2274
|
+
# Mark this as a subagent instance for SessionStart hook
|
|
2275
|
+
instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
|
|
2276
|
+
# Prepend agent instance awareness to system prompt
|
|
2277
|
+
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
2278
|
+
agent_content = agent_prefix + agent_content
|
|
2279
|
+
# Use agent's model and tools if specified and not overridden in claude_args
|
|
2280
|
+
agent_model = agent_config.get('model')
|
|
2281
|
+
agent_tools = agent_config.get('tools')
|
|
2282
|
+
claude_cmd, _ = build_claude_command(
|
|
2283
|
+
agent_content=agent_content,
|
|
2284
|
+
claude_args=claude_args,
|
|
2285
|
+
initial_prompt=initial_prompt,
|
|
2286
|
+
model=agent_model,
|
|
2287
|
+
tools=agent_tools
|
|
2288
|
+
)
|
|
2289
|
+
# Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
|
|
2290
|
+
except (FileNotFoundError, ValueError) as e:
|
|
2291
|
+
print(str(e), file=sys.stderr)
|
|
2292
|
+
continue
|
|
2293
|
+
|
|
2294
|
+
try:
|
|
2295
|
+
if command.background:
|
|
2296
|
+
log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
|
|
2297
|
+
if log_file:
|
|
2298
|
+
print(f"Background instance launched, log: {log_file}")
|
|
2299
|
+
launched += 1
|
|
2300
|
+
else:
|
|
2301
|
+
if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
|
|
2302
|
+
launched += 1
|
|
2303
|
+
except Exception as e:
|
|
2304
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
2305
|
+
|
|
2306
|
+
requested = total_instances
|
|
2061
2307
|
failed = requested - launched
|
|
2062
2308
|
|
|
2063
2309
|
if launched == 0:
|
|
@@ -2077,21 +2323,22 @@ def cmd_open(*args):
|
|
|
2077
2323
|
# Only auto-watch if ALL instances launched successfully
|
|
2078
2324
|
if terminal_mode == 'new_window' and auto_watch and failed == 0 and is_interactive():
|
|
2079
2325
|
# Show tips first if needed
|
|
2080
|
-
if prefix:
|
|
2081
|
-
print(f"\n • Send to {prefix} team: hcom send '@{prefix} message'")
|
|
2326
|
+
if command.prefix:
|
|
2327
|
+
print(f"\n • Send to {command.prefix} team: hcom send '@{command.prefix} message'")
|
|
2082
2328
|
|
|
2083
2329
|
# Clear transition message
|
|
2084
2330
|
print("\nOpening hcom watch...")
|
|
2085
2331
|
time.sleep(2) # Brief pause so user sees the message
|
|
2086
2332
|
|
|
2087
2333
|
# Launch interactive watch dashboard in current terminal
|
|
2088
|
-
|
|
2334
|
+
watch_cmd = WatchCommand(mode='interactive', wait_seconds=None)
|
|
2335
|
+
return cmd_watch(watch_cmd)
|
|
2089
2336
|
else:
|
|
2090
2337
|
tips = [
|
|
2091
2338
|
"Run 'hcom watch' to view/send in conversation dashboard",
|
|
2092
2339
|
]
|
|
2093
|
-
if prefix:
|
|
2094
|
-
tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
|
|
2340
|
+
if command.prefix:
|
|
2341
|
+
tips.append(f"Send to {command.prefix} team: hcom send '@{command.prefix} message'")
|
|
2095
2342
|
|
|
2096
2343
|
if tips:
|
|
2097
2344
|
print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
|
|
@@ -2109,40 +2356,20 @@ def cmd_open(*args):
|
|
|
2109
2356
|
print(str(e), file=sys.stderr)
|
|
2110
2357
|
return 1
|
|
2111
2358
|
|
|
2112
|
-
def cmd_watch(
|
|
2359
|
+
def cmd_watch(command: WatchCommand):
|
|
2113
2360
|
"""View conversation dashboard"""
|
|
2114
2361
|
log_file = hcom_path(LOG_FILE)
|
|
2115
2362
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2116
|
-
|
|
2363
|
+
|
|
2117
2364
|
if not log_file.exists() and not instances_dir.exists():
|
|
2118
2365
|
print(format_error("No conversation log found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2119
2366
|
return 1
|
|
2120
|
-
|
|
2121
|
-
#
|
|
2122
|
-
show_logs =
|
|
2123
|
-
show_status =
|
|
2124
|
-
wait_timeout =
|
|
2125
|
-
|
|
2126
|
-
i = 0
|
|
2127
|
-
while i < len(args):
|
|
2128
|
-
arg = args[i]
|
|
2129
|
-
if arg == '--logs':
|
|
2130
|
-
show_logs = True
|
|
2131
|
-
elif arg == '--status':
|
|
2132
|
-
show_status = True
|
|
2133
|
-
elif arg == '--wait':
|
|
2134
|
-
# Check if next arg is a number
|
|
2135
|
-
if i + 1 < len(args) and args[i + 1].isdigit():
|
|
2136
|
-
wait_timeout = int(args[i + 1])
|
|
2137
|
-
i += 1 # Skip the number
|
|
2138
|
-
else:
|
|
2139
|
-
wait_timeout = 60 # Default
|
|
2140
|
-
i += 1
|
|
2141
|
-
|
|
2142
|
-
# If wait is specified, enable logs to show the messages
|
|
2143
|
-
if wait_timeout is not None:
|
|
2144
|
-
show_logs = True
|
|
2145
|
-
|
|
2367
|
+
|
|
2368
|
+
# Determine mode
|
|
2369
|
+
show_logs = command.mode in ('logs', 'wait')
|
|
2370
|
+
show_status = command.mode == 'status'
|
|
2371
|
+
wait_timeout = command.wait_seconds
|
|
2372
|
+
|
|
2146
2373
|
# Non-interactive mode (no TTY or flags specified)
|
|
2147
2374
|
if not is_interactive() or show_logs or show_status:
|
|
2148
2375
|
if show_logs:
|
|
@@ -2209,7 +2436,9 @@ def cmd_watch(*args):
|
|
|
2209
2436
|
status_counts = {}
|
|
2210
2437
|
|
|
2211
2438
|
for name, data in positions.items():
|
|
2212
|
-
|
|
2439
|
+
if not should_show_in_watch(data):
|
|
2440
|
+
continue
|
|
2441
|
+
status, age, _ = get_instance_status(data)
|
|
2213
2442
|
instances[name] = {
|
|
2214
2443
|
"status": status,
|
|
2215
2444
|
"age": age.strip() if age else "",
|
|
@@ -2218,7 +2447,6 @@ def cmd_watch(*args):
|
|
|
2218
2447
|
"last_status": data.get("last_status", ""),
|
|
2219
2448
|
"last_status_time": data.get("last_status_time", 0),
|
|
2220
2449
|
"last_status_context": data.get("last_status_context", ""),
|
|
2221
|
-
"pid": data.get("pid"),
|
|
2222
2450
|
"background": bool(data.get("background"))
|
|
2223
2451
|
}
|
|
2224
2452
|
status_counts[status] = status_counts.get(status, 0) + 1
|
|
@@ -2324,8 +2552,7 @@ def cmd_watch(*args):
|
|
|
2324
2552
|
last_pos = log_file.stat().st_size
|
|
2325
2553
|
|
|
2326
2554
|
if message and message.strip():
|
|
2327
|
-
|
|
2328
|
-
send_message(sender_name, message.strip())
|
|
2555
|
+
cmd_send_cli(message.strip())
|
|
2329
2556
|
print(f"{FG_GREEN}✓ Sent{RESET}")
|
|
2330
2557
|
|
|
2331
2558
|
print()
|
|
@@ -2343,35 +2570,22 @@ def cmd_watch(*args):
|
|
|
2343
2570
|
|
|
2344
2571
|
def cmd_clear():
|
|
2345
2572
|
"""Clear and archive conversation"""
|
|
2346
|
-
log_file = hcom_path(LOG_FILE
|
|
2573
|
+
log_file = hcom_path(LOG_FILE)
|
|
2347
2574
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2348
2575
|
archive_folder = hcom_path(ARCHIVE_DIR)
|
|
2349
|
-
archive_folder.mkdir(exist_ok=True)
|
|
2350
2576
|
|
|
2351
|
-
#
|
|
2577
|
+
# cleanup: temp files, old scripts, old outbox files
|
|
2578
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2352
2579
|
if instances_dir.exists():
|
|
2353
|
-
|
|
2354
|
-
if deleted_count > 0:
|
|
2355
|
-
print(f"Cleaned up {deleted_count} temp files")
|
|
2580
|
+
sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
|
|
2356
2581
|
|
|
2357
|
-
# Clean up old script files (older than 24 hours)
|
|
2358
2582
|
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
2359
2583
|
if scripts_dir.exists():
|
|
2360
|
-
|
|
2361
|
-
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)
|
|
2362
|
-
if script_count > 0:
|
|
2363
|
-
print(f"Cleaned up {script_count} old script files")
|
|
2364
|
-
|
|
2365
|
-
# Clean up old launch mapping files (older than 24 hours)
|
|
2366
|
-
if instances_dir.exists():
|
|
2367
|
-
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2368
|
-
mapping_count = sum(1 for f in instances_dir.glob('.launch_map_*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
|
|
2369
|
-
if mapping_count > 0:
|
|
2370
|
-
print(f"Cleaned up {mapping_count} old launch mapping files")
|
|
2584
|
+
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)
|
|
2371
2585
|
|
|
2372
2586
|
# Check if hcom files exist
|
|
2373
2587
|
if not log_file.exists() and not instances_dir.exists():
|
|
2374
|
-
print("No
|
|
2588
|
+
print("No HCOM conversation to clear")
|
|
2375
2589
|
return 0
|
|
2376
2590
|
|
|
2377
2591
|
# Archive existing files if they have content
|
|
@@ -2385,7 +2599,7 @@ def cmd_clear():
|
|
|
2385
2599
|
if has_log or has_instances:
|
|
2386
2600
|
# Create session archive folder with timestamp
|
|
2387
2601
|
session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
|
|
2388
|
-
session_archive.mkdir(exist_ok=True)
|
|
2602
|
+
session_archive.mkdir(parents=True, exist_ok=True)
|
|
2389
2603
|
|
|
2390
2604
|
# Archive log file
|
|
2391
2605
|
if has_log:
|
|
@@ -2398,16 +2612,12 @@ def cmd_clear():
|
|
|
2398
2612
|
# Archive instances
|
|
2399
2613
|
if has_instances:
|
|
2400
2614
|
archive_instances = session_archive / INSTANCES_DIR
|
|
2401
|
-
archive_instances.mkdir(exist_ok=True)
|
|
2615
|
+
archive_instances.mkdir(parents=True, exist_ok=True)
|
|
2402
2616
|
|
|
2403
2617
|
# Move json files only
|
|
2404
2618
|
for f in instances_dir.glob('*.json'):
|
|
2405
2619
|
f.rename(archive_instances / f.name)
|
|
2406
2620
|
|
|
2407
|
-
# Clean up orphaned mapping files (position files are archived)
|
|
2408
|
-
for f in instances_dir.glob('.launch_map_*'):
|
|
2409
|
-
f.unlink(missing_ok=True)
|
|
2410
|
-
|
|
2411
2621
|
archived = True
|
|
2412
2622
|
else:
|
|
2413
2623
|
# Clean up empty files/dirs
|
|
@@ -2421,7 +2631,7 @@ def cmd_clear():
|
|
|
2421
2631
|
|
|
2422
2632
|
if archived:
|
|
2423
2633
|
print(f"Archived to archive/session-{timestamp}/")
|
|
2424
|
-
print("Started fresh
|
|
2634
|
+
print("Started fresh HCOM conversation log")
|
|
2425
2635
|
return 0
|
|
2426
2636
|
|
|
2427
2637
|
except Exception as e:
|
|
@@ -2450,16 +2660,16 @@ def cleanup_directory_hooks(directory):
|
|
|
2450
2660
|
return 1, "Cannot read Claude settings"
|
|
2451
2661
|
|
|
2452
2662
|
hooks_found = False
|
|
2453
|
-
|
|
2663
|
+
|
|
2454
2664
|
# Include PostToolUse for backward compatibility cleanup
|
|
2455
2665
|
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2456
|
-
for event in
|
|
2666
|
+
for event in LEGACY_HOOK_TYPES)
|
|
2457
2667
|
|
|
2458
2668
|
_remove_hcom_hooks_from_settings(settings)
|
|
2459
2669
|
|
|
2460
2670
|
# Check if any were removed
|
|
2461
2671
|
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2462
|
-
for event in
|
|
2672
|
+
for event in LEGACY_HOOK_TYPES)
|
|
2463
2673
|
if new_hook_count < original_hook_count:
|
|
2464
2674
|
hooks_found = True
|
|
2465
2675
|
|
|
@@ -2482,52 +2692,192 @@ def cleanup_directory_hooks(directory):
|
|
|
2482
2692
|
return 1, format_error(f"Cannot modify settings.local.json: {e}")
|
|
2483
2693
|
|
|
2484
2694
|
|
|
2485
|
-
def
|
|
2486
|
-
"""
|
|
2695
|
+
def cmd_stop(command: StopCommand):
|
|
2696
|
+
"""Stop instances, remove hooks, or archive - consolidated stop operations"""
|
|
2487
2697
|
|
|
2488
|
-
|
|
2489
|
-
|
|
2698
|
+
# Handle special targets
|
|
2699
|
+
if command.target == 'hooking':
|
|
2700
|
+
# hcom stop hooking [--all]
|
|
2701
|
+
if command.close_all_hooks:
|
|
2702
|
+
return cmd_cleanup('--all')
|
|
2703
|
+
else:
|
|
2704
|
+
return cmd_cleanup()
|
|
2490
2705
|
|
|
2491
|
-
|
|
2492
|
-
|
|
2706
|
+
elif command.target == 'everything':
|
|
2707
|
+
# hcom stop everything: stop all + archive + remove hooks
|
|
2708
|
+
print("Stopping HCOM for all instances, archiving conversation, and removing hooks...")
|
|
2493
2709
|
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2710
|
+
# Stop all instances
|
|
2711
|
+
positions = load_all_positions()
|
|
2712
|
+
if positions:
|
|
2713
|
+
for instance_name in positions.keys():
|
|
2714
|
+
disable_instance(instance_name)
|
|
2715
|
+
print(f"Stopped HCOM for {len(positions)} instance(s)")
|
|
2497
2716
|
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
status, age = get_instance_status(target_data)
|
|
2501
|
-
instance_type = "background" if target_data.get('background') else "foreground"
|
|
2717
|
+
# Archive conversation
|
|
2718
|
+
clear_result = cmd_clear()
|
|
2502
2719
|
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
# Try graceful termination first
|
|
2506
|
-
terminate_process(pid, force=False)
|
|
2507
|
-
|
|
2508
|
-
# Wait for process to exit gracefully
|
|
2509
|
-
for _ in range(20):
|
|
2510
|
-
time.sleep(KILL_CHECK_INTERVAL)
|
|
2511
|
-
if not is_process_alive(pid):
|
|
2512
|
-
# Process terminated successfully
|
|
2513
|
-
break
|
|
2514
|
-
else:
|
|
2515
|
-
# Process didn't die from graceful attempt, force kill
|
|
2516
|
-
terminate_process(pid, force=True)
|
|
2517
|
-
time.sleep(0.1)
|
|
2720
|
+
# Remove hooks from all directories
|
|
2721
|
+
cleanup_result = cmd_cleanup('--all')
|
|
2518
2722
|
|
|
2519
|
-
|
|
2520
|
-
killed_count += 1
|
|
2521
|
-
except (TypeError, ValueError) as e:
|
|
2522
|
-
print(f"Process {pid} invalid: {e}")
|
|
2723
|
+
return max(clear_result, cleanup_result)
|
|
2523
2724
|
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2725
|
+
elif command.target == 'all':
|
|
2726
|
+
# hcom stop all: stop all instances + archive
|
|
2727
|
+
positions = load_all_positions()
|
|
2527
2728
|
|
|
2528
|
-
|
|
2529
|
-
|
|
2729
|
+
if not positions:
|
|
2730
|
+
print("No instances found")
|
|
2731
|
+
# Still archive if there's conversation history
|
|
2732
|
+
return cmd_clear()
|
|
2733
|
+
|
|
2734
|
+
stopped_count = 0
|
|
2735
|
+
bg_logs = []
|
|
2736
|
+
for instance_name, instance_data in positions.items():
|
|
2737
|
+
if instance_data.get('enabled', False):
|
|
2738
|
+
disable_instance(instance_name)
|
|
2739
|
+
print(f"Stopped HCOM for {instance_name}")
|
|
2740
|
+
stopped_count += 1
|
|
2741
|
+
|
|
2742
|
+
# Track background logs
|
|
2743
|
+
if instance_data.get('background'):
|
|
2744
|
+
log_file = instance_data.get('background_log_file', '')
|
|
2745
|
+
if log_file:
|
|
2746
|
+
bg_logs.append((instance_name, log_file))
|
|
2747
|
+
|
|
2748
|
+
if stopped_count == 0:
|
|
2749
|
+
print("All instances already stopped")
|
|
2750
|
+
else:
|
|
2751
|
+
print(f"Stopped {stopped_count} instance(s)")
|
|
2752
|
+
|
|
2753
|
+
# Show background logs if any
|
|
2754
|
+
if bg_logs:
|
|
2755
|
+
print("\nBackground logs:")
|
|
2756
|
+
for name, log_file in bg_logs:
|
|
2757
|
+
print(f" {name}: {log_file}")
|
|
2758
|
+
print("\nMonitor: tail -f <log_file>")
|
|
2759
|
+
print("Force stop: hcom stop --force all")
|
|
2760
|
+
|
|
2761
|
+
# Archive conversation
|
|
2762
|
+
return cmd_clear()
|
|
2763
|
+
|
|
2764
|
+
else:
|
|
2765
|
+
# hcom stop [alias] or hcom stop (self)
|
|
2766
|
+
|
|
2767
|
+
# Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
|
|
2768
|
+
if not check_and_update_hooks():
|
|
2769
|
+
return 1
|
|
2770
|
+
|
|
2771
|
+
# Stop specific instance or self
|
|
2772
|
+
# Get instance name from injected session or target
|
|
2773
|
+
if command._hcom_session and not command.target:
|
|
2774
|
+
instance_name, _ = resolve_instance_name(command._hcom_session, os.environ.get('HCOM_PREFIX'))
|
|
2775
|
+
else:
|
|
2776
|
+
instance_name = command.target
|
|
2777
|
+
|
|
2778
|
+
position = load_instance_position(instance_name) if instance_name else None
|
|
2779
|
+
|
|
2780
|
+
if not instance_name:
|
|
2781
|
+
print("Error: Could not determine instance. Run inside Claude Code or specify alias. Or run hcom stop <alias> or hcom stop all")
|
|
2782
|
+
return 1
|
|
2783
|
+
|
|
2784
|
+
if not position:
|
|
2785
|
+
print(f"No instance found for {instance_name}")
|
|
2786
|
+
return 1
|
|
2787
|
+
|
|
2788
|
+
# Skip already stopped instances (unless forcing)
|
|
2789
|
+
if not position.get('enabled', False) and not command.force:
|
|
2790
|
+
print(f"HCOM already stopped for {instance_name}")
|
|
2791
|
+
return 0
|
|
2792
|
+
|
|
2793
|
+
# Disable instance (optionally with force)
|
|
2794
|
+
disable_instance(instance_name, force=command.force)
|
|
2530
2795
|
|
|
2796
|
+
if command.force:
|
|
2797
|
+
print(f"⚠️ Force stopped HCOM for {instance_name}.")
|
|
2798
|
+
print(f" Bash tool use is now DENIED. Instance is locked down.")
|
|
2799
|
+
print(f" To restart: hcom start {instance_name}")
|
|
2800
|
+
else:
|
|
2801
|
+
print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
|
|
2802
|
+
|
|
2803
|
+
# Show background log location if applicable
|
|
2804
|
+
if position.get('background'):
|
|
2805
|
+
log_file = position.get('background_log_file', '')
|
|
2806
|
+
if log_file:
|
|
2807
|
+
print(f"\nBackground log: {log_file}")
|
|
2808
|
+
print(f"Monitor: tail -f {log_file}")
|
|
2809
|
+
if not command.force:
|
|
2810
|
+
print(f"Force stop: hcom stop --force {instance_name}")
|
|
2811
|
+
|
|
2812
|
+
return 0
|
|
2813
|
+
|
|
2814
|
+
def cmd_start(command: StartCommand):
|
|
2815
|
+
"""Enable HCOM participation for instances"""
|
|
2816
|
+
|
|
2817
|
+
# Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
|
|
2818
|
+
if not check_and_update_hooks():
|
|
2819
|
+
return 1
|
|
2820
|
+
|
|
2821
|
+
# Get instance name from injected session or target
|
|
2822
|
+
if command._hcom_session and not command.target:
|
|
2823
|
+
instance_name, existing_data = resolve_instance_name(command._hcom_session, os.environ.get('HCOM_PREFIX'))
|
|
2824
|
+
|
|
2825
|
+
# Create instance if it doesn't exist (opt-in for vanilla instances)
|
|
2826
|
+
if not existing_data:
|
|
2827
|
+
initialize_instance_in_position_file(instance_name, command._hcom_session)
|
|
2828
|
+
# Enable instance (clears all stop flags)
|
|
2829
|
+
enable_instance(instance_name)
|
|
2830
|
+
print(f"Started HCOM for this instance. Your alias is: {instance_name}")
|
|
2831
|
+
else:
|
|
2832
|
+
# Skip already started instances
|
|
2833
|
+
if existing_data.get('enabled', False):
|
|
2834
|
+
print(f"HCOM already started for {instance_name}")
|
|
2835
|
+
return 0
|
|
2836
|
+
|
|
2837
|
+
# Re-enabling existing instance
|
|
2838
|
+
enable_instance(instance_name)
|
|
2839
|
+
print(f"Started HCOM for {instance_name}. Rejoined chat.")
|
|
2840
|
+
|
|
2841
|
+
return 0
|
|
2842
|
+
|
|
2843
|
+
# Handle hooking target
|
|
2844
|
+
if command.target == 'hooking':
|
|
2845
|
+
# hcom start hooking: install hooks in current directory
|
|
2846
|
+
if setup_hooks():
|
|
2847
|
+
print("HCOM hooks installed in current directory")
|
|
2848
|
+
print("Hooks active on next Claude Code launch in this directory")
|
|
2849
|
+
return 0
|
|
2850
|
+
else:
|
|
2851
|
+
return 1
|
|
2852
|
+
|
|
2853
|
+
# CLI path: start specific instance
|
|
2854
|
+
positions = load_all_positions()
|
|
2855
|
+
|
|
2856
|
+
# Handle missing target from external CLI
|
|
2857
|
+
if not command.target:
|
|
2858
|
+
print("Error: No instance specified & Not run by Claude Code\n")
|
|
2859
|
+
print("Run by Claude Code: 'hcom start' starts HCOM for the current instance")
|
|
2860
|
+
print("From anywhere: 'hcom start <alias>'\n")
|
|
2861
|
+
print("To launch new instances: 'hcom open'")
|
|
2862
|
+
return 1
|
|
2863
|
+
|
|
2864
|
+
# Start specific instance
|
|
2865
|
+
instance_name = command.target
|
|
2866
|
+
position = positions.get(instance_name)
|
|
2867
|
+
|
|
2868
|
+
if not position:
|
|
2869
|
+
print(f"Instance not found: {instance_name}")
|
|
2870
|
+
return 1
|
|
2871
|
+
|
|
2872
|
+
# Skip already started instances
|
|
2873
|
+
if position.get('enabled', False):
|
|
2874
|
+
print(f"HCOM already started for {instance_name}")
|
|
2875
|
+
return 0
|
|
2876
|
+
|
|
2877
|
+
# Enable instance (clears all stop flags)
|
|
2878
|
+
enable_instance(instance_name)
|
|
2879
|
+
|
|
2880
|
+
print(f"Started HCOM for {instance_name}. Rejoined chat.")
|
|
2531
2881
|
return 0
|
|
2532
2882
|
|
|
2533
2883
|
def cmd_cleanup(*args):
|
|
@@ -2546,7 +2896,7 @@ def cmd_cleanup(*args):
|
|
|
2546
2896
|
print(f"Warning: Could not read current instances: {e}")
|
|
2547
2897
|
|
|
2548
2898
|
if not directories:
|
|
2549
|
-
print("No directories found in current
|
|
2899
|
+
print("No directories found in current HCOM tracking")
|
|
2550
2900
|
return 0
|
|
2551
2901
|
|
|
2552
2902
|
print(f"Found {len(directories)} unique directories to check")
|
|
@@ -2587,8 +2937,48 @@ def cmd_cleanup(*args):
|
|
|
2587
2937
|
print(message)
|
|
2588
2938
|
return exit_code
|
|
2589
2939
|
|
|
2590
|
-
def
|
|
2591
|
-
"""
|
|
2940
|
+
def check_and_update_hooks() -> bool:
|
|
2941
|
+
"""Verify hooks are correct when running inside Claude Code, reinstall if needed.
|
|
2942
|
+
Returns True if hooks are good (continue), False if hooks were updated (restart needed)."""
|
|
2943
|
+
if os.environ.get('CLAUDECODE') != '1':
|
|
2944
|
+
return True # Not in Claude Code, continue normally
|
|
2945
|
+
|
|
2946
|
+
# Check both cwd and home for hooks
|
|
2947
|
+
cwd_settings = Path.cwd() / '.claude' / 'settings.local.json'
|
|
2948
|
+
home_settings = Path.home() / '.claude' / 'settings.local.json'
|
|
2949
|
+
|
|
2950
|
+
# If hooks are correctly installed, continue
|
|
2951
|
+
if verify_hooks_installed(cwd_settings) or verify_hooks_installed(home_settings):
|
|
2952
|
+
return True
|
|
2953
|
+
|
|
2954
|
+
# Hooks missing or incorrect - reinstall them
|
|
2955
|
+
try:
|
|
2956
|
+
setup_hooks()
|
|
2957
|
+
print("Hooks updated. Restart Claude Code to use HCOM.", file=sys.stderr)
|
|
2958
|
+
except Exception as e:
|
|
2959
|
+
print(f"Failed to update hooks: {e}", file=sys.stderr)
|
|
2960
|
+
print("Try running: hcom open from normal terminal", file=sys.stderr)
|
|
2961
|
+
return False
|
|
2962
|
+
|
|
2963
|
+
def cmd_send(command: SendCommand, force_cli=False):
|
|
2964
|
+
"""Send message to hcom, force cli for config sender instead of instance generated name"""
|
|
2965
|
+
|
|
2966
|
+
# Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
|
|
2967
|
+
if not check_and_update_hooks():
|
|
2968
|
+
return 1
|
|
2969
|
+
|
|
2970
|
+
# Handle resume command - pass caller session_id
|
|
2971
|
+
if command.resume_alias:
|
|
2972
|
+
caller_session = command._hcom_session # May be None for CLI
|
|
2973
|
+
return cmd_resume_merge(command.resume_alias, caller_session)
|
|
2974
|
+
|
|
2975
|
+
message = command.message
|
|
2976
|
+
|
|
2977
|
+
# Check message is provided
|
|
2978
|
+
if not message:
|
|
2979
|
+
print(format_error("No message provided"), file=sys.stderr)
|
|
2980
|
+
return 1
|
|
2981
|
+
|
|
2592
2982
|
# Check if hcom files exist
|
|
2593
2983
|
log_file = hcom_path(LOG_FILE)
|
|
2594
2984
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
@@ -2609,80 +2999,107 @@ def cmd_send(message):
|
|
|
2609
2999
|
try:
|
|
2610
3000
|
positions = load_all_positions()
|
|
2611
3001
|
all_instances = list(positions.keys())
|
|
3002
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
3003
|
+
all_names = all_instances + [sender_name]
|
|
2612
3004
|
unmatched = [m for m in mentions
|
|
2613
|
-
if not any(name.lower().startswith(m.lower()) for name in
|
|
3005
|
+
if not any(name.lower().startswith(m.lower()) for name in all_names)]
|
|
2614
3006
|
if unmatched:
|
|
2615
3007
|
print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
|
|
2616
3008
|
except Exception:
|
|
2617
3009
|
pass # Don't fail on warning
|
|
2618
3010
|
|
|
2619
|
-
# Determine sender
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
if launch_id:
|
|
3011
|
+
# Determine sender from injected session_id or CLI
|
|
3012
|
+
if command._hcom_session and not force_cli:
|
|
3013
|
+
# Instance context - get name from session_id
|
|
2623
3014
|
try:
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
pass
|
|
3015
|
+
sender_name = get_display_name(command._hcom_session)
|
|
3016
|
+
except (ValueError, Exception) as e:
|
|
3017
|
+
print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
|
|
3018
|
+
return 1
|
|
2629
3019
|
|
|
2630
|
-
|
|
2631
|
-
sender_name = get_config_value('sender_name', 'bigboss')
|
|
3020
|
+
instance_data = load_instance_position(sender_name)
|
|
2632
3021
|
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
3022
|
+
# Initialize instance if doesn't exist (first use)
|
|
3023
|
+
if not instance_data:
|
|
3024
|
+
initialize_instance_in_position_file(sender_name, command._hcom_session)
|
|
3025
|
+
instance_data = load_instance_position(sender_name)
|
|
3026
|
+
|
|
3027
|
+
# Check force_closed
|
|
3028
|
+
if instance_data.get('force_closed'):
|
|
3029
|
+
print(format_error(f"HCOM force stopped for this instance. To recover, delete instance file: rm ~/.hcom/instances/{sender_name}.json"), file=sys.stderr)
|
|
3030
|
+
return 1
|
|
3031
|
+
|
|
3032
|
+
# Check enabled state
|
|
3033
|
+
if not instance_data.get('enabled', False):
|
|
3034
|
+
print(format_error("HCOM not started for this instance. To send a message first run: 'hcom start' then use hcom send"), file=sys.stderr)
|
|
3035
|
+
return 1
|
|
3036
|
+
|
|
3037
|
+
# Send message
|
|
3038
|
+
if not send_message(sender_name, message):
|
|
3039
|
+
print(format_error("Failed to send message"), file=sys.stderr)
|
|
3040
|
+
return 1
|
|
3041
|
+
|
|
3042
|
+
# Show unread messages
|
|
3043
|
+
messages = get_unread_messages(sender_name, update_position=True)
|
|
3044
|
+
if messages:
|
|
3045
|
+
max_msgs = get_config_value('max_messages_per_delivery', 50)
|
|
3046
|
+
formatted = format_hook_messages(messages[:max_msgs], sender_name)
|
|
3047
|
+
print(f"Message sent\n\n{formatted}", file=sys.stderr)
|
|
2644
3048
|
else:
|
|
2645
|
-
# Bigboss: just confirm send
|
|
2646
3049
|
print("Message sent", file=sys.stderr)
|
|
2647
|
-
|
|
3050
|
+
|
|
2648
3051
|
# Show cli_hints if configured (non-interactive mode)
|
|
2649
3052
|
if not is_interactive():
|
|
2650
3053
|
show_cli_hints()
|
|
2651
|
-
|
|
3054
|
+
|
|
2652
3055
|
return 0
|
|
2653
3056
|
else:
|
|
2654
|
-
|
|
2655
|
-
|
|
3057
|
+
# CLI context - no session_id or force_cli=True
|
|
3058
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
2656
3059
|
|
|
2657
|
-
|
|
2658
|
-
|
|
3060
|
+
if not send_message(sender_name, message):
|
|
3061
|
+
print(format_error("Failed to send message"), file=sys.stderr)
|
|
3062
|
+
return 1
|
|
3063
|
+
|
|
3064
|
+
print(f"✓ Sent from {sender_name}", file=sys.stderr)
|
|
3065
|
+
|
|
3066
|
+
# Show cli_hints if configured (non-interactive mode)
|
|
3067
|
+
if not is_interactive():
|
|
3068
|
+
show_cli_hints()
|
|
2659
3069
|
|
|
2660
|
-
|
|
3070
|
+
return 0
|
|
3071
|
+
|
|
3072
|
+
def cmd_send_cli(message):
|
|
3073
|
+
"""Force CLI sender (skip outbox, use config sender name)"""
|
|
3074
|
+
command = SendCommand(message=message, resume_alias=None)
|
|
3075
|
+
return cmd_send(command, force_cli=True)
|
|
3076
|
+
|
|
3077
|
+
def cmd_resume_merge(alias: str, caller_session: str | None = None) -> int:
|
|
3078
|
+
"""Resume/merge current instance into an existing instance by alias.
|
|
3079
|
+
INTERNAL COMMAND: Only called via 'hcom send --resume alias' during implicit resume workflow.
|
|
2661
3080
|
Not meant for direct CLI usage.
|
|
3081
|
+
Args:
|
|
3082
|
+
alias: Target instance alias to merge into
|
|
3083
|
+
caller_session: Session ID of caller (injected by PreToolUse hook) or None for CLI
|
|
2662
3084
|
"""
|
|
2663
|
-
#
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
3085
|
+
# If caller_session provided (from hcom send --resume), use it
|
|
3086
|
+
if caller_session:
|
|
3087
|
+
instance_name = get_display_name(caller_session)
|
|
3088
|
+
else:
|
|
3089
|
+
# CLI path - no session context
|
|
3090
|
+
print(format_error("Not in HCOM instance context"), file=sys.stderr)
|
|
2668
3091
|
return 1
|
|
2669
3092
|
|
|
2670
|
-
instance_name = None
|
|
2671
|
-
try:
|
|
2672
|
-
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
|
|
2673
|
-
if mapping_file.exists():
|
|
2674
|
-
instance_name = mapping_file.read_text(encoding='utf-8').strip()
|
|
2675
|
-
except Exception:
|
|
2676
|
-
pass
|
|
2677
|
-
|
|
2678
3093
|
if not instance_name:
|
|
2679
3094
|
print(format_error("Could not determine instance name"), file=sys.stderr)
|
|
2680
3095
|
return 1
|
|
2681
3096
|
|
|
2682
|
-
# Sanitize alias:
|
|
3097
|
+
# Sanitize alias: must be valid instance name format
|
|
3098
|
+
# Base: 5 lowercase alphanumeric (e.g., hova3)
|
|
3099
|
+
# Prefixed: {prefix}-{5 chars} (e.g., api-hova3, cool-team-kolec)
|
|
2683
3100
|
# This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
|
|
2684
|
-
if not re.match(r'^[
|
|
2685
|
-
print(format_error("Invalid alias format.
|
|
3101
|
+
if not re.match(r'^([a-zA-Z0-9_-]+-)?[a-z0-9]{5}$', alias):
|
|
3102
|
+
print(format_error("Invalid alias format. Must be 5-char instance name or prefix-name format"), file=sys.stderr)
|
|
2686
3103
|
return 1
|
|
2687
3104
|
|
|
2688
3105
|
# Attempt to merge current instance into target alias
|
|
@@ -2691,7 +3108,14 @@ def cmd_resume_merge(alias: str) -> int:
|
|
|
2691
3108
|
# Handle results
|
|
2692
3109
|
if not status:
|
|
2693
3110
|
# Empty status means names matched (from_name == to_name)
|
|
2694
|
-
status = f"[SUCCESS] ✓ Already using alias {alias}"
|
|
3111
|
+
status = f"[SUCCESS] ✓ Already using HCOM alias {alias}. Rejoined chat."
|
|
3112
|
+
elif status.startswith('[SUCCESS]'):
|
|
3113
|
+
# Merge successful - update message
|
|
3114
|
+
status = f"[SUCCESS] ✓ Resumed HCOM as {alias}. Rejoined chat."
|
|
3115
|
+
|
|
3116
|
+
# If merge successful, enable instance (clears session_ended and stop flags)
|
|
3117
|
+
if status.startswith('[SUCCESS]'):
|
|
3118
|
+
enable_instance(alias)
|
|
2695
3119
|
|
|
2696
3120
|
# Print status and return
|
|
2697
3121
|
print(status, file=sys.stderr)
|
|
@@ -2717,143 +3141,168 @@ def format_hook_messages(messages, instance_name):
|
|
|
2717
3141
|
|
|
2718
3142
|
# ==================== Hook Handlers ====================
|
|
2719
3143
|
|
|
3144
|
+
def detect_session_scenario(
|
|
3145
|
+
hook_type: str | None,
|
|
3146
|
+
session_id: str,
|
|
3147
|
+
source: str,
|
|
3148
|
+
existing_data: dict | None
|
|
3149
|
+
) -> SessionScenario | None:
|
|
3150
|
+
"""
|
|
3151
|
+
Detect session startup scenario explicitly.
|
|
3152
|
+
|
|
3153
|
+
Returns:
|
|
3154
|
+
SessionScenario for definitive scenarios
|
|
3155
|
+
None for deferred decision (SessionStart with wrong session_id)
|
|
3156
|
+
"""
|
|
3157
|
+
if existing_data is not None:
|
|
3158
|
+
# Found existing instance with matching session_id
|
|
3159
|
+
return SessionScenario.MATCHED_RESUME
|
|
3160
|
+
|
|
3161
|
+
if hook_type == 'sessionstart' and source == 'resume':
|
|
3162
|
+
# SessionStart on resume without match = wrong session_id
|
|
3163
|
+
# Don't know if truly unmatched yet - UserPromptSubmit will decide
|
|
3164
|
+
return None # Deferred decision
|
|
3165
|
+
|
|
3166
|
+
if hook_type == 'userpromptsubmit' and source == 'resume':
|
|
3167
|
+
# UserPromptSubmit on resume without match = definitively unmatched
|
|
3168
|
+
return SessionScenario.UNMATCHED_RESUME
|
|
3169
|
+
|
|
3170
|
+
# Normal startup
|
|
3171
|
+
return SessionScenario.FRESH_START
|
|
3172
|
+
|
|
3173
|
+
|
|
3174
|
+
def should_create_instance_file(scenario: SessionScenario | None, hook_type: str | None) -> bool:
|
|
3175
|
+
"""
|
|
3176
|
+
Decide whether to create instance file NOW.
|
|
3177
|
+
|
|
3178
|
+
Simplified: Only UserPromptSubmit creates instances.
|
|
3179
|
+
SessionStart just shows minimal message and tracks status.
|
|
3180
|
+
"""
|
|
3181
|
+
# Only UserPromptSubmit creates instances
|
|
3182
|
+
if hook_type != 'userpromptsubmit':
|
|
3183
|
+
return False
|
|
3184
|
+
|
|
3185
|
+
# Create for new scenarios only (not matched resume which already exists)
|
|
3186
|
+
return scenario in (SessionScenario.FRESH_START, SessionScenario.UNMATCHED_RESUME)
|
|
3187
|
+
|
|
3188
|
+
|
|
2720
3189
|
def init_hook_context(hook_data, hook_type=None):
|
|
2721
|
-
"""
|
|
2722
|
-
|
|
3190
|
+
"""
|
|
3191
|
+
Initialize instance context with explicit scenario detection.
|
|
2723
3192
|
|
|
3193
|
+
Flow:
|
|
3194
|
+
1. Resolve instance name (search by session_id, generate if not found)
|
|
3195
|
+
2. Detect scenario (fresh/matched/unmatched/deferred)
|
|
3196
|
+
3. Decide whether to create file NOW
|
|
3197
|
+
4. Return context with all decisions made
|
|
3198
|
+
"""
|
|
2724
3199
|
session_id = hook_data.get('session_id', '')
|
|
2725
3200
|
transcript_path = hook_data.get('transcript_path', '')
|
|
3201
|
+
source = hook_data.get('source', 'startup')
|
|
2726
3202
|
prefix = os.environ.get('HCOM_PREFIX')
|
|
2727
3203
|
|
|
2728
|
-
|
|
2729
|
-
instance_name =
|
|
2730
|
-
merged_state = None
|
|
2731
|
-
|
|
2732
|
-
# Check if current session_id matches any existing instance
|
|
2733
|
-
# This maintains identity after resume/merge operations
|
|
2734
|
-
if not instance_name and session_id and instances_dir.exists():
|
|
2735
|
-
for instance_file in instances_dir.glob("*.json"):
|
|
2736
|
-
try:
|
|
2737
|
-
data = load_instance_position(instance_file.stem)
|
|
2738
|
-
if session_id == data.get('session_id'):
|
|
2739
|
-
instance_name = instance_file.stem
|
|
2740
|
-
merged_state = data
|
|
2741
|
-
log_hook_error(f'DEBUG: Session_id {session_id[:8]} matched {instance_file.stem}, reusing that name')
|
|
2742
|
-
break
|
|
2743
|
-
except (json.JSONDecodeError, OSError, KeyError):
|
|
2744
|
-
continue
|
|
2745
|
-
|
|
2746
|
-
# If not found or not resuming, generate new name from session_id
|
|
2747
|
-
if not instance_name:
|
|
2748
|
-
instance_name = get_display_name(session_id, prefix)
|
|
2749
|
-
# DEBUG: Log name generation
|
|
2750
|
-
log_hook_error(f'DEBUG: Generated instance_name={instance_name} from session_id={session_id[:8] if session_id else "None"}')
|
|
2751
|
-
|
|
2752
|
-
# Save launch_id → instance_name mapping for cmd_send()
|
|
2753
|
-
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2754
|
-
if launch_id:
|
|
2755
|
-
try:
|
|
2756
|
-
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}', ensure_parent=True)
|
|
2757
|
-
mapping_file.write_text(instance_name, encoding='utf-8')
|
|
2758
|
-
log_hook_error(f'DEBUG: FINAL - Wrote launch_map_{launch_id} → {instance_name} (session_id={session_id[:8] if session_id else "None"})')
|
|
2759
|
-
except Exception:
|
|
2760
|
-
pass # Non-critical
|
|
3204
|
+
# Step 1: Resolve instance name
|
|
3205
|
+
instance_name, existing_data = resolve_instance_name(session_id, prefix)
|
|
2761
3206
|
|
|
2762
3207
|
# Save migrated data if we have it
|
|
2763
|
-
if
|
|
2764
|
-
save_instance_position(instance_name,
|
|
3208
|
+
if existing_data:
|
|
3209
|
+
save_instance_position(instance_name, existing_data)
|
|
3210
|
+
|
|
3211
|
+
# Step 2: Detect scenario
|
|
3212
|
+
scenario = detect_session_scenario(hook_type, session_id, source, existing_data)
|
|
2765
3213
|
|
|
2766
|
-
# Check if instance is brand new
|
|
3214
|
+
# Check if instance is brand new (before creation - for bypass logic)
|
|
2767
3215
|
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
2768
3216
|
is_new_instance = not instance_file.exists()
|
|
2769
3217
|
|
|
2770
|
-
|
|
2771
|
-
#
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
if should_create_instance:
|
|
3218
|
+
|
|
3219
|
+
# Step 3: Decide creation
|
|
3220
|
+
should_create = should_create_instance_file(scenario, hook_type)
|
|
3221
|
+
|
|
3222
|
+
|
|
3223
|
+
if should_create:
|
|
2777
3224
|
initialize_instance_in_position_file(instance_name, session_id)
|
|
2778
|
-
existing_data = load_instance_position(instance_name) if should_create_instance else {}
|
|
2779
3225
|
|
|
2780
|
-
#
|
|
3226
|
+
# Step 4: Build updates dict
|
|
2781
3227
|
updates: dict[str, Any] = {
|
|
2782
3228
|
'directory': str(Path.cwd()),
|
|
2783
3229
|
}
|
|
2784
3230
|
|
|
2785
|
-
# Update session_id (overwrites previous)
|
|
2786
3231
|
if session_id:
|
|
2787
3232
|
updates['session_id'] = session_id
|
|
2788
3233
|
|
|
2789
|
-
# Update transcript_path to current
|
|
2790
3234
|
if transcript_path:
|
|
2791
3235
|
updates['transcript_path'] = transcript_path
|
|
2792
3236
|
|
|
2793
|
-
# Always update PID to current (fixes stale PID on implicit resume)
|
|
2794
|
-
updates['pid'] = os.getppid()
|
|
2795
|
-
|
|
2796
|
-
# Add background status if applicable
|
|
2797
3237
|
bg_env = os.environ.get('HCOM_BACKGROUND')
|
|
2798
3238
|
if bg_env:
|
|
2799
3239
|
updates['background'] = True
|
|
2800
3240
|
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
2801
3241
|
|
|
2802
|
-
# Return
|
|
2803
|
-
is_resume_match =
|
|
2804
|
-
|
|
3242
|
+
# Return compatible with existing callers
|
|
3243
|
+
is_resume_match = (scenario == SessionScenario.MATCHED_RESUME)
|
|
3244
|
+
|
|
3245
|
+
|
|
3246
|
+
return instance_name, updates, is_resume_match, is_new_instance
|
|
3247
|
+
|
|
3248
|
+
def pretooluse_decision(decision: str, reason: str) -> None:
|
|
3249
|
+
"""Exit PreToolUse hook with permission decision"""
|
|
3250
|
+
output = {
|
|
3251
|
+
"hookSpecificOutput": {
|
|
3252
|
+
"hookEventName": "PreToolUse",
|
|
3253
|
+
"permissionDecision": decision,
|
|
3254
|
+
"permissionDecisionReason": reason
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
3258
|
+
sys.exit(EXIT_SUCCESS)
|
|
2805
3259
|
|
|
2806
3260
|
def handle_pretooluse(hook_data, instance_name, updates):
|
|
2807
|
-
"""Handle PreToolUse hook -
|
|
3261
|
+
"""Handle PreToolUse hook - check force_closed, inject session_id"""
|
|
3262
|
+
instance_data = load_instance_position(instance_name)
|
|
2808
3263
|
tool_name = hook_data.get('tool_name', '')
|
|
3264
|
+
session_id = hook_data.get('session_id', '')
|
|
2809
3265
|
|
|
2810
|
-
#
|
|
2811
|
-
|
|
3266
|
+
# FORCE CLOSE CHECK - deny ALL tools
|
|
3267
|
+
if instance_data.get('force_closed'):
|
|
3268
|
+
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.")
|
|
2812
3269
|
|
|
2813
|
-
|
|
3270
|
+
# Record status for tool execution tracking (only if enabled)
|
|
3271
|
+
if instance_data.get('enabled', False):
|
|
3272
|
+
set_status(instance_name, 'tool_pending', tool_name)
|
|
2814
3273
|
|
|
2815
|
-
#
|
|
2816
|
-
if tool_name == 'Bash':
|
|
3274
|
+
# Inject session_id into hcom send/stop/start commands via updatedInput
|
|
3275
|
+
if tool_name == 'Bash' and session_id:
|
|
2817
3276
|
command = hook_data.get('tool_input', {}).get('command', '')
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
#
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
#
|
|
2824
|
-
if (
|
|
2825
|
-
|
|
2826
|
-
|
|
3277
|
+
|
|
3278
|
+
# Match: (hcom|uvx hcom|python X hcom.py|X hcom.py) (send|stop|start)
|
|
3279
|
+
# Handles: hcom, uvx hcom, python hcom.py, python /path/to/hcom.py, hcom.py, /path/to/hcom.py
|
|
3280
|
+
hcom_pattern = r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+(send|stop|start)\b'
|
|
3281
|
+
|
|
3282
|
+
# Check if command contains any hcom invocations
|
|
3283
|
+
if re.search(hcom_pattern, command):
|
|
3284
|
+
# Inject session_id after EACH hcom command (handles chained commands)
|
|
3285
|
+
modified_command = re.sub(hcom_pattern, rf'\g<0> --_hcom_session {session_id}', command)
|
|
3286
|
+
|
|
2827
3287
|
output = {
|
|
2828
3288
|
"hookSpecificOutput": {
|
|
2829
3289
|
"hookEventName": "PreToolUse",
|
|
2830
3290
|
"permissionDecision": "allow",
|
|
2831
|
-
"
|
|
3291
|
+
"updatedInput": {
|
|
3292
|
+
"command": modified_command
|
|
3293
|
+
}
|
|
2832
3294
|
}
|
|
2833
3295
|
}
|
|
2834
3296
|
print(json.dumps(output, ensure_ascii=False))
|
|
2835
3297
|
sys.exit(EXIT_SUCCESS)
|
|
2836
3298
|
|
|
2837
3299
|
|
|
2838
|
-
def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
|
|
2839
|
-
"""Safely exit stop hook with proper status tracking"""
|
|
2840
|
-
try:
|
|
2841
|
-
set_status(instance_name, 'stop_exit')
|
|
2842
|
-
except (OSError, PermissionError):
|
|
2843
|
-
pass # Silently handle any errors
|
|
2844
|
-
sys.exit(code)
|
|
2845
3300
|
|
|
2846
3301
|
def handle_stop(hook_data, instance_name, updates):
|
|
2847
3302
|
"""Handle Stop hook - poll for messages and deliver"""
|
|
2848
|
-
import time as time_module
|
|
2849
|
-
|
|
2850
|
-
parent_pid = os.getppid()
|
|
2851
|
-
log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
|
|
2852
|
-
log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
|
|
2853
|
-
|
|
2854
3303
|
|
|
2855
3304
|
try:
|
|
2856
|
-
entry_time =
|
|
3305
|
+
entry_time = time.time()
|
|
2857
3306
|
updates['last_stop'] = entry_time
|
|
2858
3307
|
timeout = get_config_value('wait_timeout', 1800)
|
|
2859
3308
|
updates['wait_timeout'] = timeout
|
|
@@ -2864,32 +3313,32 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
2864
3313
|
except Exception as e:
|
|
2865
3314
|
log_hook_error(f'stop:update_instance_position({instance_name})', e)
|
|
2866
3315
|
|
|
2867
|
-
start_time =
|
|
2868
|
-
log_hook_error(f'stop:start_time_pid_{os.getpid()}')
|
|
3316
|
+
start_time = time.time()
|
|
2869
3317
|
|
|
2870
3318
|
try:
|
|
2871
3319
|
loop_count = 0
|
|
2872
|
-
|
|
2873
|
-
|
|
3320
|
+
last_heartbeat = start_time
|
|
3321
|
+
# Actual polling loop - this IS the holding pattern
|
|
3322
|
+
while time.time() - start_time < timeout:
|
|
2874
3323
|
if loop_count == 0:
|
|
2875
|
-
|
|
3324
|
+
time.sleep(0.1) # Initial wait before first poll
|
|
2876
3325
|
loop_count += 1
|
|
2877
3326
|
|
|
2878
|
-
#
|
|
2879
|
-
|
|
2880
|
-
log_hook_error(f'stop:parent_died_pid_{os.getpid()}')
|
|
2881
|
-
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
3327
|
+
# Load instance data once per poll
|
|
3328
|
+
instance_data = load_instance_position(instance_name)
|
|
2882
3329
|
|
|
2883
|
-
|
|
2884
|
-
if
|
|
2885
|
-
|
|
2886
|
-
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
3330
|
+
# Check if session ended (SessionEnd hook fired) - exit without changing status
|
|
3331
|
+
if instance_data.get('session_ended'):
|
|
3332
|
+
sys.exit(EXIT_SUCCESS) # Don't overwrite session_ended status
|
|
2887
3333
|
|
|
2888
|
-
# Check if user input is pending - exit cleanly if
|
|
2889
|
-
|
|
2890
|
-
if
|
|
2891
|
-
|
|
2892
|
-
|
|
3334
|
+
# Check if user input is pending - exit cleanly if recent input
|
|
3335
|
+
last_user_input = instance_data.get('last_user_input', 0)
|
|
3336
|
+
if time.time() - last_user_input < 0.2:
|
|
3337
|
+
sys.exit(EXIT_SUCCESS) # Don't overwrite status - let current status remain
|
|
3338
|
+
|
|
3339
|
+
# Check if closed - exit cleanly
|
|
3340
|
+
if not instance_data.get('enabled', False):
|
|
3341
|
+
sys.exit(EXIT_SUCCESS) # Preserve 'stopped' status set by cmd_stop
|
|
2893
3342
|
|
|
2894
3343
|
# Check for new messages and deliver
|
|
2895
3344
|
if messages := get_unread_messages(instance_name, update_position=True):
|
|
@@ -2897,19 +3346,20 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
2897
3346
|
reason = format_hook_messages(messages_to_show, instance_name)
|
|
2898
3347
|
set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
|
|
2899
3348
|
|
|
2900
|
-
log_hook_error(f'stop:delivering_message_pid_{os.getpid()}')
|
|
2901
3349
|
output = {"decision": "block", "reason": reason}
|
|
2902
3350
|
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
2903
3351
|
sys.exit(EXIT_BLOCK)
|
|
2904
3352
|
|
|
2905
|
-
# Update heartbeat
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
3353
|
+
# Update heartbeat every 0.5 seconds for staleness detection
|
|
3354
|
+
now = time.time()
|
|
3355
|
+
if now - last_heartbeat >= 0.5:
|
|
3356
|
+
try:
|
|
3357
|
+
update_instance_position(instance_name, {'last_stop': now})
|
|
3358
|
+
last_heartbeat = now
|
|
3359
|
+
except Exception as e:
|
|
3360
|
+
log_hook_error(f'stop:heartbeat_update({instance_name})', e)
|
|
2911
3361
|
|
|
2912
|
-
|
|
3362
|
+
time.sleep(STOP_HOOK_POLL_INTERVAL)
|
|
2913
3363
|
|
|
2914
3364
|
except Exception as loop_e:
|
|
2915
3365
|
# Log polling loop errors but continue to cleanup
|
|
@@ -2921,7 +3371,7 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
2921
3371
|
except Exception as e:
|
|
2922
3372
|
# Log error and exit gracefully
|
|
2923
3373
|
log_hook_error('handle_stop', e)
|
|
2924
|
-
|
|
3374
|
+
sys.exit(EXIT_SUCCESS) # Preserve previous status on exception
|
|
2925
3375
|
|
|
2926
3376
|
def handle_notify(hook_data, instance_name, updates):
|
|
2927
3377
|
"""Handle Notification hook - track permission requests"""
|
|
@@ -2929,124 +3379,186 @@ def handle_notify(hook_data, instance_name, updates):
|
|
|
2929
3379
|
update_instance_position(instance_name, updates)
|
|
2930
3380
|
set_status(instance_name, 'blocked', hook_data.get('message', ''))
|
|
2931
3381
|
|
|
3382
|
+
def wait_for_stop_exit(instance_name, max_wait=0.2):
|
|
3383
|
+
"""Wait for Stop hook to exit. Returns wait time in ms."""
|
|
3384
|
+
start = time.time()
|
|
3385
|
+
|
|
3386
|
+
while time.time() - start < max_wait:
|
|
3387
|
+
time.sleep(0.01)
|
|
3388
|
+
|
|
3389
|
+
data = load_instance_position(instance_name)
|
|
3390
|
+
last_stop_age = time.time() - data.get('last_stop', 0)
|
|
3391
|
+
|
|
3392
|
+
if last_stop_age > 0.2:
|
|
3393
|
+
return int((time.time() - start) * 1000)
|
|
3394
|
+
|
|
3395
|
+
return int((time.time() - start) * 1000)
|
|
3396
|
+
|
|
2932
3397
|
def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
|
|
2933
3398
|
"""Handle UserPromptSubmit hook - track when user sends messages"""
|
|
2934
|
-
|
|
3399
|
+
# Load instance data for coordination check and alias_announced
|
|
3400
|
+
instance_data = load_instance_position(instance_name)
|
|
3401
|
+
is_enabled = instance_data.get('enabled', False)
|
|
3402
|
+
last_stop = instance_data.get('last_stop', 0)
|
|
3403
|
+
alias_announced = instance_data.get('alias_announced', False)
|
|
2935
3404
|
|
|
2936
|
-
#
|
|
2937
|
-
|
|
2938
|
-
update_instance_position(instance_name, updates)
|
|
3405
|
+
# Coordinate with Stop hook only if enabled AND Stop hook is active
|
|
3406
|
+
stop_is_active = (time.time() - last_stop) < 1.0
|
|
2939
3407
|
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
log_hook_error(f'userpromptsubmit:signal_file_unlinked_pid_{os.getpid()}')
|
|
2947
|
-
signal_file.unlink()
|
|
2948
|
-
except (OSError, PermissionError) as e:
|
|
2949
|
-
log_hook_error(f'userpromptsubmit:signal_file_error', e)
|
|
2950
|
-
|
|
2951
|
-
send_cmd = build_send_command('your message')
|
|
3408
|
+
if is_enabled and stop_is_active:
|
|
3409
|
+
updates['last_user_input'] = time.time()
|
|
3410
|
+
update_instance_position(instance_name, updates)
|
|
3411
|
+
wait_ms = wait_for_stop_exit(instance_name)
|
|
3412
|
+
|
|
3413
|
+
send_cmd = build_send_command('your message', instance_name)
|
|
2952
3414
|
resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
|
|
3415
|
+
|
|
2953
3416
|
# Build message based on what happened
|
|
2954
3417
|
msg = None
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
3418
|
+
|
|
3419
|
+
# Determine if this is an HCOM-launched instance
|
|
3420
|
+
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
3421
|
+
|
|
3422
|
+
# PRIORITY 1: Handle unmatched resume FIRST (is_new_instance=True means SessionStart was skipped)
|
|
3423
|
+
# This must show critical recovery message regardless of HCOM_LAUNCHED status
|
|
3424
|
+
if is_new_instance:
|
|
3425
|
+
msg = build_hcom_bootstrap_text(instance_name)
|
|
3426
|
+
msg += (
|
|
3427
|
+
f"\n\n[CRITICAL: HCOM RESUME DETECTED - You MUST recover your identity to maintain conversation context. "
|
|
3428
|
+
f"Run this command: {resume_cmd} - This is REQUIRED for message history and position tracking]"
|
|
2964
3429
|
)
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
if
|
|
2970
|
-
|
|
3430
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
3431
|
+
|
|
3432
|
+
# PRIORITY 2: Normal startup - show bootstrap if not already announced
|
|
3433
|
+
elif not alias_announced:
|
|
3434
|
+
if is_hcom_launched:
|
|
3435
|
+
# HCOM-launched instance - show bootstrap immediately
|
|
3436
|
+
msg = build_hcom_bootstrap_text(instance_name)
|
|
2971
3437
|
update_instance_position(instance_name, {'alias_announced': True})
|
|
3438
|
+
else:
|
|
3439
|
+
# Vanilla Claude instance - check if user is about to run an hcom command
|
|
3440
|
+
user_prompt = hook_data.get('prompt', '')
|
|
3441
|
+
hcom_command_pattern = r'\bhcom\s+\w+'
|
|
3442
|
+
if re.search(hcom_command_pattern, user_prompt, re.IGNORECASE):
|
|
3443
|
+
# Bootstrap not shown yet - show it preemptively before hcom command runs
|
|
3444
|
+
msg = "[HCOM COMMAND DETECTED]\n\n"
|
|
3445
|
+
msg += build_hcom_bootstrap_text(instance_name)
|
|
3446
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
3447
|
+
|
|
3448
|
+
# PRIORITY 3: Add resume status note if we showed bootstrap for a matched resume
|
|
3449
|
+
if msg and is_resume_match and not is_new_instance:
|
|
3450
|
+
if is_enabled:
|
|
3451
|
+
msg += "\n[Session resumed. HCOM started for this instance - will receive chat messages. Your alias and conversation history preserved.]"
|
|
3452
|
+
else:
|
|
3453
|
+
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.]"
|
|
2972
3454
|
|
|
2973
3455
|
if msg:
|
|
2974
|
-
output = {
|
|
2975
|
-
|
|
3456
|
+
output = {
|
|
3457
|
+
# "systemMessage": "HCOM enabled",
|
|
3458
|
+
"hookSpecificOutput": {
|
|
3459
|
+
"hookEventName": "UserPromptSubmit",
|
|
3460
|
+
"additionalContext": msg
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
print(json.dumps(output), file=sys.stdout)
|
|
3464
|
+
# sys.exit(1)
|
|
2976
3465
|
|
|
2977
3466
|
def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
|
|
2978
|
-
"""Handle SessionStart hook -
|
|
3467
|
+
"""Handle SessionStart hook - minimal message, full details on first prompt"""
|
|
2979
3468
|
source = hook_data.get('source', 'startup')
|
|
2980
3469
|
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
log_hook_error(f'sessionstart:hook_data_{hook_data}')
|
|
2986
|
-
|
|
2987
|
-
# Reset alias_announced flag so alias shows again on resume/clear/compact
|
|
2988
|
-
updates['alias_announced'] = False
|
|
2989
|
-
|
|
2990
|
-
# Only update instance position if file exists (startup or matched resume)
|
|
2991
|
-
# For unmatched resumes, skip - UserPromptSubmit will create the file with correct session_id
|
|
2992
|
-
if source == 'startup' or is_resume_match:
|
|
3470
|
+
# Update instance if it exists (matched resume only, since we don't create in SessionStart anymore)
|
|
3471
|
+
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
3472
|
+
if instance_file.exists():
|
|
3473
|
+
updates['alias_announced'] = False
|
|
2993
3474
|
update_instance_position(instance_name, updates)
|
|
2994
3475
|
set_status(instance_name, 'session_start')
|
|
2995
3476
|
|
|
2996
|
-
|
|
3477
|
+
# Minimal message - no alias yet (UserPromptSubmit will show full details)
|
|
3478
|
+
help_text = "[HCOM active. Submit a prompt to initialize.]"
|
|
2997
3479
|
|
|
2998
|
-
#
|
|
2999
|
-
|
|
3000
|
-
help_text = f"[Welcome! HCOM chat active. Send messages: {send_cmd}]"
|
|
3001
|
-
|
|
3002
|
-
# Add subagent type if this is a named agent
|
|
3003
|
-
subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
|
|
3004
|
-
if subagent_type:
|
|
3005
|
-
help_text += f" [Subagent: {subagent_type}]"
|
|
3006
|
-
|
|
3007
|
-
# Add first use text only on startup
|
|
3008
|
-
if source == 'startup':
|
|
3480
|
+
# Add first_use_text only for hcom-launched instances on startup
|
|
3481
|
+
if os.environ.get('HCOM_LAUNCHED') == '1' and source == 'startup':
|
|
3009
3482
|
first_use_text = get_config_value('first_use_text', '')
|
|
3010
3483
|
if first_use_text:
|
|
3011
3484
|
help_text += f" [{first_use_text}]"
|
|
3012
|
-
elif source == 'resume':
|
|
3013
|
-
if is_resume_match:
|
|
3014
|
-
help_text += f" [Resumed alias: {instance_name}]"
|
|
3015
|
-
else:
|
|
3016
|
-
help_text += f" [Session resumed]"
|
|
3017
3485
|
|
|
3018
|
-
# Add instance hints to all messages
|
|
3019
|
-
instance_hints = get_config_value('instance_hints', '')
|
|
3020
|
-
if instance_hints:
|
|
3021
|
-
help_text += f" [{instance_hints}]"
|
|
3022
|
-
|
|
3023
|
-
# Output as additionalContext using hookSpecificOutput format
|
|
3024
3486
|
output = {
|
|
3025
3487
|
"hookSpecificOutput": {
|
|
3026
3488
|
"hookEventName": "SessionStart",
|
|
3027
3489
|
"additionalContext": help_text
|
|
3028
3490
|
}
|
|
3029
3491
|
}
|
|
3492
|
+
|
|
3030
3493
|
print(json.dumps(output))
|
|
3031
3494
|
|
|
3495
|
+
def handle_sessionend(hook_data, instance_name, updates):
|
|
3496
|
+
"""Handle SessionEnd hook - mark session as ended and set final status"""
|
|
3497
|
+
reason = hook_data.get('reason', 'unknown')
|
|
3498
|
+
|
|
3499
|
+
# Set session_ended flag to tell Stop hook to exit
|
|
3500
|
+
updates['session_ended'] = True
|
|
3501
|
+
|
|
3502
|
+
# Set status with reason as context (reason: clear, logout, prompt_input_exit, other)
|
|
3503
|
+
set_status(instance_name, 'session_ended', reason)
|
|
3504
|
+
|
|
3505
|
+
try:
|
|
3506
|
+
update_instance_position(instance_name, updates)
|
|
3507
|
+
except Exception as e:
|
|
3508
|
+
log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
|
|
3509
|
+
|
|
3032
3510
|
def handle_hook(hook_type: str) -> None:
|
|
3033
3511
|
"""Unified hook handler for all HCOM hooks"""
|
|
3034
|
-
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
3035
|
-
sys.exit(EXIT_SUCCESS)
|
|
3036
|
-
|
|
3037
3512
|
hook_data = json.load(sys.stdin)
|
|
3038
|
-
log_hook_error(f'handle_hook:hook_data_{hook_data}')
|
|
3039
3513
|
|
|
3040
|
-
|
|
3514
|
+
if not ensure_hcom_directories():
|
|
3515
|
+
log_hook_error('handle_hook', Exception('Failed to create directories'))
|
|
3516
|
+
sys.exit(EXIT_SUCCESS)
|
|
3517
|
+
|
|
3041
3518
|
session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
|
|
3042
|
-
|
|
3519
|
+
source_debug = hook_data.get('source', 'NO_SOURCE')
|
|
3520
|
+
|
|
3521
|
+
# Vanilla instance check (not hcom-launched)
|
|
3522
|
+
# Exit early if no instance file exists, except:
|
|
3523
|
+
# - PreToolUse (handles first send opt-in)
|
|
3524
|
+
# - UserPromptSubmit with hcom command (shows preemptive bootstrap)
|
|
3525
|
+
if hook_type != 'pre' and os.environ.get('HCOM_LAUNCHED') != '1':
|
|
3526
|
+
session_id = hook_data.get('session_id', '')
|
|
3527
|
+
if not session_id:
|
|
3528
|
+
sys.exit(EXIT_SUCCESS)
|
|
3529
|
+
|
|
3530
|
+
instance_name = get_display_name(session_id, os.environ.get('HCOM_PREFIX'))
|
|
3531
|
+
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
3532
|
+
|
|
3533
|
+
if not instance_file.exists():
|
|
3534
|
+
# Allow UserPromptSubmit through if prompt contains hcom command
|
|
3535
|
+
if hook_type == 'userpromptsubmit':
|
|
3536
|
+
user_prompt = hook_data.get('prompt', '')
|
|
3537
|
+
if not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE):
|
|
3538
|
+
sys.exit(EXIT_SUCCESS)
|
|
3539
|
+
# Continue - let handle_userpromptsubmit show bootstrap
|
|
3540
|
+
else:
|
|
3541
|
+
sys.exit(EXIT_SUCCESS)
|
|
3043
3542
|
|
|
3044
|
-
|
|
3543
|
+
# Initialize instance context (creates file if needed, reuses existing if session_id matches)
|
|
3544
|
+
instance_name, updates, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
|
|
3545
|
+
|
|
3546
|
+
# Special bypass for unmatched resume - must show critical warning even if disabled
|
|
3547
|
+
# is_new_instance=True in UserPromptSubmit means SessionStart didn't create it (was skipped)
|
|
3548
|
+
if hook_type == 'userpromptsubmit' and is_new_instance:
|
|
3549
|
+
handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
|
|
3550
|
+
sys.exit(EXIT_SUCCESS)
|
|
3551
|
+
|
|
3552
|
+
# Check enabled status (PreToolUse handles toggle, so exempt)
|
|
3553
|
+
if hook_type != 'pre':
|
|
3554
|
+
instance_data = load_instance_position(instance_name)
|
|
3555
|
+
if not instance_data.get('enabled', False):
|
|
3556
|
+
sys.exit(EXIT_SUCCESS)
|
|
3045
3557
|
|
|
3046
3558
|
match hook_type:
|
|
3047
3559
|
case 'pre':
|
|
3048
3560
|
handle_pretooluse(hook_data, instance_name, updates)
|
|
3049
|
-
case '
|
|
3561
|
+
case 'poll':
|
|
3050
3562
|
handle_stop(hook_data, instance_name, updates)
|
|
3051
3563
|
case 'notify':
|
|
3052
3564
|
handle_notify(hook_data, instance_name, updates)
|
|
@@ -3054,9 +3566,8 @@ def handle_hook(hook_type: str) -> None:
|
|
|
3054
3566
|
handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
|
|
3055
3567
|
case 'sessionstart':
|
|
3056
3568
|
handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3569
|
+
case 'sessionend':
|
|
3570
|
+
handle_sessionend(hook_data, instance_name, updates)
|
|
3060
3571
|
|
|
3061
3572
|
sys.exit(EXIT_SUCCESS)
|
|
3062
3573
|
|
|
@@ -3066,48 +3577,71 @@ def handle_hook(hook_type: str) -> None:
|
|
|
3066
3577
|
def main(argv=None):
|
|
3067
3578
|
"""Main command dispatcher"""
|
|
3068
3579
|
if argv is None:
|
|
3069
|
-
argv = sys.argv
|
|
3070
|
-
|
|
3071
|
-
|
|
3580
|
+
argv = sys.argv[1:]
|
|
3581
|
+
else:
|
|
3582
|
+
argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
|
|
3583
|
+
|
|
3584
|
+
# Check for help
|
|
3585
|
+
if needs_help(argv):
|
|
3072
3586
|
return cmd_help()
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
# Not meant for regular CLI usage. Primary usage:
|
|
3094
|
-
# - From instances: $HCOM send "message" (instances send messages to each other)
|
|
3095
|
-
# - From CLI: hcom send "message" (user/claude orchestrator sends to instances)
|
|
3096
|
-
if argv[2] == '--resume':
|
|
3097
|
-
if len(argv) < 4:
|
|
3098
|
-
print(format_error("Alias required for --resume"), file=sys.stderr)
|
|
3099
|
-
return 1
|
|
3100
|
-
return cmd_resume_merge(argv[3])
|
|
3101
|
-
|
|
3102
|
-
return cmd_send(argv[2])
|
|
3103
|
-
case 'kill':
|
|
3104
|
-
return cmd_kill(*argv[2:])
|
|
3105
|
-
case 'stop' | 'notify' | 'pre' | 'sessionstart' | 'userpromptsubmit':
|
|
3106
|
-
handle_hook(cmd)
|
|
3107
|
-
return 0
|
|
3108
|
-
case _:
|
|
3109
|
-
print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
|
|
3587
|
+
|
|
3588
|
+
# Handle hook commands (special case - no parsing needed)
|
|
3589
|
+
if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
|
|
3590
|
+
handle_hook(argv[0])
|
|
3591
|
+
return 0
|
|
3592
|
+
|
|
3593
|
+
# Handle send_cli (hidden command)
|
|
3594
|
+
if argv and argv[0] == 'send_cli':
|
|
3595
|
+
if len(argv) < 2:
|
|
3596
|
+
print(format_error("Message required"), file=sys.stderr)
|
|
3597
|
+
return 1
|
|
3598
|
+
return cmd_send_cli(argv[1])
|
|
3599
|
+
|
|
3600
|
+
# Split on -- separator for forwarding args
|
|
3601
|
+
hcom_args, forwarded = split_forwarded_args(argv)
|
|
3602
|
+
|
|
3603
|
+
# Ensure directories exist for commands that need them
|
|
3604
|
+
if hcom_args and hcom_args[0] not in ('help', '--help', '-h'):
|
|
3605
|
+
if not ensure_hcom_directories():
|
|
3606
|
+
print(format_error("Failed to create HCOM directories"), file=sys.stderr)
|
|
3110
3607
|
return 1
|
|
3111
3608
|
|
|
3609
|
+
# Build parser and parse arguments
|
|
3610
|
+
parser = build_parser()
|
|
3611
|
+
|
|
3612
|
+
try:
|
|
3613
|
+
namespace = parser.parse_args(hcom_args)
|
|
3614
|
+
except SystemExit as exc:
|
|
3615
|
+
if exc.code != 0:
|
|
3616
|
+
print("Run 'hcom -h' for help", file=sys.stderr)
|
|
3617
|
+
return exc.code if isinstance(exc.code, int) else 1
|
|
3618
|
+
|
|
3619
|
+
# Dispatch to command parsers and get typed command objects
|
|
3620
|
+
try:
|
|
3621
|
+
command_obj = dispatch(namespace, forwarded)
|
|
3622
|
+
|
|
3623
|
+
# command_obj could be exit code (from help) or command dataclass
|
|
3624
|
+
if isinstance(command_obj, int):
|
|
3625
|
+
return command_obj
|
|
3626
|
+
|
|
3627
|
+
# Execute the command with typed object
|
|
3628
|
+
if isinstance(command_obj, OpenCommand):
|
|
3629
|
+
return cmd_open(command_obj)
|
|
3630
|
+
elif isinstance(command_obj, WatchCommand):
|
|
3631
|
+
return cmd_watch(command_obj)
|
|
3632
|
+
elif isinstance(command_obj, StopCommand):
|
|
3633
|
+
return cmd_stop(command_obj)
|
|
3634
|
+
elif isinstance(command_obj, StartCommand):
|
|
3635
|
+
return cmd_start(command_obj)
|
|
3636
|
+
elif isinstance(command_obj, SendCommand):
|
|
3637
|
+
return cmd_send(command_obj)
|
|
3638
|
+
else:
|
|
3639
|
+
print(format_error(f"Unknown command type: {type(command_obj)}"), file=sys.stderr)
|
|
3640
|
+
return 1
|
|
3641
|
+
|
|
3642
|
+
except CLIError as exc:
|
|
3643
|
+
print(str(exc), file=sys.stderr)
|
|
3644
|
+
return 1
|
|
3645
|
+
|
|
3112
3646
|
if __name__ == '__main__':
|
|
3113
3647
|
sys.exit(main())
|