hcom 0.4.2.post3__py3-none-any.whl → 0.6.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 +2 -2
- hcom/__main__.py +3 -3743
- hcom/cli.py +4613 -0
- hcom/shared.py +1036 -0
- hcom/ui.py +2965 -0
- hcom-0.6.0.dist-info/METADATA +269 -0
- hcom-0.6.0.dist-info/RECORD +10 -0
- hcom-0.4.2.post3.dist-info/METADATA +0 -452
- hcom-0.4.2.post3.dist-info/RECORD +0 -7
- {hcom-0.4.2.post3.dist-info → hcom-0.6.0.dist-info}/WHEEL +0 -0
- {hcom-0.4.2.post3.dist-info → hcom-0.6.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.4.2.post3.dist-info → hcom-0.6.0.dist-info}/top_level.txt +0 -0
hcom/cli.py
ADDED
|
@@ -0,0 +1,4613 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
hcom
|
|
4
|
+
CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import io
|
|
11
|
+
import tempfile
|
|
12
|
+
import shutil
|
|
13
|
+
import shlex
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
import select
|
|
18
|
+
import platform
|
|
19
|
+
import random
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
from typing import Any, Callable, NamedTuple, TextIO
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
|
|
26
|
+
if os.name == 'nt':
|
|
27
|
+
import msvcrt
|
|
28
|
+
else:
|
|
29
|
+
import fcntl
|
|
30
|
+
|
|
31
|
+
# Import from shared module
|
|
32
|
+
from .shared import (
|
|
33
|
+
__version__,
|
|
34
|
+
# ANSI codes
|
|
35
|
+
RESET, FG_YELLOW,
|
|
36
|
+
# Config
|
|
37
|
+
DEFAULT_CONFIG_HEADER, DEFAULT_CONFIG_DEFAULTS,
|
|
38
|
+
parse_env_file, parse_env_value, format_env_value,
|
|
39
|
+
# Utilities
|
|
40
|
+
format_age, get_status_counts,
|
|
41
|
+
# Claude args parsing
|
|
42
|
+
resolve_claude_args, add_background_defaults, validate_conflicts,
|
|
43
|
+
extract_system_prompt_args, merge_system_prompts,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Backwards compatibility for modules importing legacy helpers
|
|
47
|
+
_parse_env_value = parse_env_value
|
|
48
|
+
_format_env_value = format_env_value
|
|
49
|
+
_parse_env_file = parse_env_file
|
|
50
|
+
|
|
51
|
+
_HEADER_COMMENT_LINES: list[str] = []
|
|
52
|
+
_HEADER_DEFAULT_EXTRAS: dict[str, str] = {}
|
|
53
|
+
_header_data_started = False
|
|
54
|
+
for _line in DEFAULT_CONFIG_HEADER:
|
|
55
|
+
stripped = _line.strip()
|
|
56
|
+
if not _header_data_started and (not stripped or stripped.startswith('#')):
|
|
57
|
+
_HEADER_COMMENT_LINES.append(_line)
|
|
58
|
+
continue
|
|
59
|
+
if not _header_data_started:
|
|
60
|
+
_header_data_started = True
|
|
61
|
+
if _header_data_started and '=' in _line:
|
|
62
|
+
key, _, value = _line.partition('=')
|
|
63
|
+
_HEADER_DEFAULT_EXTRAS[key.strip()] = parse_env_value(value)
|
|
64
|
+
|
|
65
|
+
KNOWN_CONFIG_KEYS: list[str] = []
|
|
66
|
+
DEFAULT_KNOWN_VALUES: dict[str, str] = {}
|
|
67
|
+
for _entry in DEFAULT_CONFIG_DEFAULTS:
|
|
68
|
+
if '=' not in _entry:
|
|
69
|
+
continue
|
|
70
|
+
key, _, value = _entry.partition('=')
|
|
71
|
+
key = key.strip()
|
|
72
|
+
KNOWN_CONFIG_KEYS.append(key)
|
|
73
|
+
DEFAULT_KNOWN_VALUES[key] = parse_env_value(value)
|
|
74
|
+
|
|
75
|
+
if sys.version_info < (3, 10):
|
|
76
|
+
sys.exit("Error: hcom requires Python 3.10 or higher")
|
|
77
|
+
|
|
78
|
+
# ==================== Constants ====================
|
|
79
|
+
|
|
80
|
+
IS_WINDOWS = sys.platform == 'win32'
|
|
81
|
+
|
|
82
|
+
def is_wsl() -> bool:
|
|
83
|
+
"""Detect if running in WSL"""
|
|
84
|
+
if platform.system() != 'Linux':
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
with open('/proc/version', 'r') as f:
|
|
88
|
+
return 'microsoft' in f.read().lower()
|
|
89
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def is_termux() -> bool:
|
|
93
|
+
"""Detect if running in Termux on Android"""
|
|
94
|
+
return (
|
|
95
|
+
'TERMUX_VERSION' in os.environ or # Primary: Works all versions
|
|
96
|
+
'TERMUX__ROOTFS' in os.environ or # Modern: v0.119.0+
|
|
97
|
+
Path('/data/data/com.termux').exists() or # Fallback: Path check
|
|
98
|
+
'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Windows API constants
|
|
103
|
+
CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
|
|
104
|
+
|
|
105
|
+
# Timing constants
|
|
106
|
+
FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
|
|
107
|
+
STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
|
|
108
|
+
|
|
109
|
+
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@([\w-]+)')
|
|
110
|
+
AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
|
|
111
|
+
TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
|
|
112
|
+
|
|
113
|
+
# STATUS_MAP and status constants moved to shared.py (imported above)
|
|
114
|
+
# ANSI codes moved to shared.py (imported above)
|
|
115
|
+
|
|
116
|
+
# ==================== Windows/WSL Console Unicode ====================
|
|
117
|
+
|
|
118
|
+
# Apply UTF-8 encoding for Windows and WSL
|
|
119
|
+
if IS_WINDOWS or is_wsl():
|
|
120
|
+
try:
|
|
121
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
122
|
+
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
|
123
|
+
except (AttributeError, OSError):
|
|
124
|
+
pass # Fallback if stream redirection fails
|
|
125
|
+
|
|
126
|
+
# ==================== Error Handling Strategy ====================
|
|
127
|
+
# Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
|
|
128
|
+
# CLI: Can raise exceptions for user feedback. Check return values.
|
|
129
|
+
# Critical I/O: atomic_write, save_instance_position
|
|
130
|
+
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
131
|
+
|
|
132
|
+
# ==================== CLI Errors ====================
|
|
133
|
+
|
|
134
|
+
class CLIError(Exception):
|
|
135
|
+
"""Raised when arguments cannot be mapped to command semantics."""
|
|
136
|
+
|
|
137
|
+
# ==================== Help Text ====================
|
|
138
|
+
|
|
139
|
+
def get_help_text() -> str:
|
|
140
|
+
"""Generate help text with current version"""
|
|
141
|
+
return f"""hcom {__version__}
|
|
142
|
+
|
|
143
|
+
Usage: hcom # UI
|
|
144
|
+
[ENV_VARS] hcom <COUNT> [claude <ARGS>...]
|
|
145
|
+
hcom watch [--logs|--status|--wait [SEC]]
|
|
146
|
+
hcom send "message"
|
|
147
|
+
hcom stop [alias|all]
|
|
148
|
+
hcom start [alias]
|
|
149
|
+
hcom reset [logs|hooks|config]
|
|
150
|
+
|
|
151
|
+
Launch Examples:
|
|
152
|
+
hcom 3 Open 3 terminals with claude connected to hcom
|
|
153
|
+
hcom 3 claude -p + Headless
|
|
154
|
+
HCOM_TAG=api hcom 3 claude -p + @-mention group tag
|
|
155
|
+
claude 'run hcom start' claude code with prompt will also work
|
|
156
|
+
|
|
157
|
+
Commands:
|
|
158
|
+
watch messaging/status/launch UI (same as hcom no args)
|
|
159
|
+
--logs Print all messages
|
|
160
|
+
--status Print instance status JSON
|
|
161
|
+
--wait [SEC] Wait and notify for new message
|
|
162
|
+
|
|
163
|
+
send "msg" Send message to all instances
|
|
164
|
+
send "@alias msg" Send to specific instance/group
|
|
165
|
+
|
|
166
|
+
stop Stop current instance (from inside Claude)
|
|
167
|
+
stop <alias> Stop specific instance
|
|
168
|
+
stop all Stop all instances
|
|
169
|
+
|
|
170
|
+
start Start current instance (from inside Claude)
|
|
171
|
+
start <alias> Start specific instance
|
|
172
|
+
|
|
173
|
+
reset Stop all + archive logs + remove hooks + clear config
|
|
174
|
+
reset logs Clear + archive conversation log
|
|
175
|
+
reset hooks Safely remove hcom hooks from claude settings.json
|
|
176
|
+
reset config Clear + backup config.env
|
|
177
|
+
|
|
178
|
+
Environment Variables:
|
|
179
|
+
HCOM_TAG=name Group tag (creates name-* instances)
|
|
180
|
+
HCOM_AGENT=type Agent type (comma-separated for multiple)
|
|
181
|
+
HCOM_TERMINAL=mode Terminal: new|here|print|"custom {{script}}"
|
|
182
|
+
HCOM_HINTS=text Text appended to all messages received by instance
|
|
183
|
+
HCOM_TIMEOUT=secs Time until disconnected from hcom chat (default 1800s / 30mins)
|
|
184
|
+
HCOM_SUBAGENT_TIMEOUT=secs Subagent idle timeout (default 30s)
|
|
185
|
+
HCOM_CLAUDE_ARGS=args Claude CLI defaults (e.g., '-p --model opus "hello!"')
|
|
186
|
+
|
|
187
|
+
ANTHROPIC_MODEL=opus # Any env var passed through to Claude Code
|
|
188
|
+
|
|
189
|
+
Persist Env Vars in `~/.hcom/config.env`
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ==================== Logging ====================
|
|
194
|
+
|
|
195
|
+
def log_hook_error(hook_name: str, error: Exception | str | None = None) -> None:
|
|
196
|
+
"""Log hook exceptions or just general logging to ~/.hcom/.tmp/logs/hooks.log for debugging"""
|
|
197
|
+
import traceback
|
|
198
|
+
try:
|
|
199
|
+
log_file = hcom_path(LOGS_DIR) / "hooks.log"
|
|
200
|
+
timestamp = datetime.now().isoformat()
|
|
201
|
+
if error and isinstance(error, Exception):
|
|
202
|
+
tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
203
|
+
with open(log_file, 'a') as f:
|
|
204
|
+
f.write(f"{timestamp}|{hook_name}|{type(error).__name__}: {error}\n{tb}\n")
|
|
205
|
+
else:
|
|
206
|
+
with open(log_file, 'a') as f:
|
|
207
|
+
f.write(f"{timestamp}|{hook_name}|{error or 'checkpoint'}\n")
|
|
208
|
+
except (OSError, PermissionError):
|
|
209
|
+
pass # Silent failure in error logging
|
|
210
|
+
|
|
211
|
+
# ==================== File Locking ====================
|
|
212
|
+
|
|
213
|
+
@contextmanager
|
|
214
|
+
def locked(fp, timeout=5.0):
|
|
215
|
+
"""Context manager for cross-platform file locking with timeout"""
|
|
216
|
+
start = time.time()
|
|
217
|
+
|
|
218
|
+
if os.name == 'nt':
|
|
219
|
+
fp.seek(0)
|
|
220
|
+
# Lock entire file (0x7fffffff = 2GB max)
|
|
221
|
+
while time.time() - start < timeout:
|
|
222
|
+
try:
|
|
223
|
+
msvcrt.locking(fp.fileno(), msvcrt.LK_NBLCK, 0x7fffffff)
|
|
224
|
+
break
|
|
225
|
+
except OSError:
|
|
226
|
+
time.sleep(0.01)
|
|
227
|
+
else:
|
|
228
|
+
raise TimeoutError(f"Lock timeout after {timeout}s")
|
|
229
|
+
else:
|
|
230
|
+
# Non-blocking with retry
|
|
231
|
+
while time.time() - start < timeout:
|
|
232
|
+
try:
|
|
233
|
+
fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
234
|
+
break
|
|
235
|
+
except BlockingIOError:
|
|
236
|
+
time.sleep(0.01)
|
|
237
|
+
else:
|
|
238
|
+
raise TimeoutError(f"Lock timeout after {timeout}s")
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
yield
|
|
242
|
+
finally:
|
|
243
|
+
if os.name == 'nt':
|
|
244
|
+
fp.seek(0)
|
|
245
|
+
msvcrt.locking(fp.fileno(), msvcrt.LK_UNLCK, 0x7fffffff)
|
|
246
|
+
else:
|
|
247
|
+
fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
|
|
248
|
+
|
|
249
|
+
# ==================== Config Defaults ====================
|
|
250
|
+
# Config precedence: env var > ~/.hcom/config.env > defaults
|
|
251
|
+
# All config via HcomConfig dataclass (timeout, terminal, prompt, hints, tag, agent)
|
|
252
|
+
|
|
253
|
+
# Constants (not configurable)
|
|
254
|
+
MAX_MESSAGE_SIZE = 1048576 # 1MB
|
|
255
|
+
MAX_MESSAGES_PER_DELIVERY = 50
|
|
256
|
+
SENDER = 'bigboss'
|
|
257
|
+
SENDER_EMOJI = '🐳'
|
|
258
|
+
SKIP_HISTORY = True # New instances start at current log position (skip old messages)
|
|
259
|
+
|
|
260
|
+
# Path constants
|
|
261
|
+
LOG_FILE = "hcom.log"
|
|
262
|
+
INSTANCES_DIR = "instances"
|
|
263
|
+
LOGS_DIR = ".tmp/logs"
|
|
264
|
+
SCRIPTS_DIR = ".tmp/scripts"
|
|
265
|
+
FLAGS_DIR = ".tmp/flags"
|
|
266
|
+
CONFIG_FILE = "config.env"
|
|
267
|
+
ARCHIVE_DIR = "archive"
|
|
268
|
+
|
|
269
|
+
# Hook configuration - single source of truth for setup_hooks() and verify_hooks_installed()
|
|
270
|
+
# Format: (hook_type, matcher, command_suffix, timeout)
|
|
271
|
+
# Command gets built as: hook_cmd_base + ' ' + command_suffix (e.g., '${HCOM} poll')
|
|
272
|
+
HOOK_CONFIGS = [
|
|
273
|
+
('SessionStart', '', 'sessionstart', None),
|
|
274
|
+
('UserPromptSubmit', '', 'userpromptsubmit', None),
|
|
275
|
+
('PreToolUse', 'Bash|Task', 'pre', None),
|
|
276
|
+
('PostToolUse', 'Bash|Task', 'post', 86400),
|
|
277
|
+
('Stop', '', 'poll', 86400), # Poll for messages (24hr max timeout)
|
|
278
|
+
('SubagentStop', '', 'subagent-stop', 86400), # Subagent coordination (24hr max)
|
|
279
|
+
('Notification', '', 'notify', None),
|
|
280
|
+
('SessionEnd', '', 'sessionend', None),
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
# Derived from HOOK_CONFIGS - guaranteed to stay in sync
|
|
284
|
+
ACTIVE_HOOK_TYPES = [cfg[0] for cfg in HOOK_CONFIGS]
|
|
285
|
+
HOOK_COMMANDS = [cfg[2] for cfg in HOOK_CONFIGS]
|
|
286
|
+
LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES
|
|
287
|
+
LEGACY_HOOK_COMMANDS = HOOK_COMMANDS
|
|
288
|
+
|
|
289
|
+
# Hook removal patterns - used by _remove_hcom_hooks_from_settings()
|
|
290
|
+
# Dynamically build from LEGACY_HOOK_COMMANDS to match current and legacy hook formats
|
|
291
|
+
_HOOK_ARGS_PATTERN = '|'.join(LEGACY_HOOK_COMMANDS)
|
|
292
|
+
HCOM_HOOK_PATTERNS = [
|
|
293
|
+
re.compile(r'\$\{?HCOM'), # Current: Environment variable ${HCOM:-...}
|
|
294
|
+
re.compile(r'\bHCOM_ACTIVE.*hcom\.py'), # LEGACY: Unix HCOM_ACTIVE conditional
|
|
295
|
+
re.compile(r'IF\s+"%HCOM_ACTIVE%"'), # LEGACY: Windows HCOM_ACTIVE conditional
|
|
296
|
+
re.compile(rf'\bhcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: Direct hcom command
|
|
297
|
+
re.compile(rf'\buvx\s+hcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: uvx hcom command
|
|
298
|
+
re.compile(rf'hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: hcom.py with optional quote
|
|
299
|
+
re.compile(rf'["\'][^"\']*hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b(?=\s|$)'), # LEGACY: Quoted path
|
|
300
|
+
re.compile(r'sh\s+-c.*hcom'), # LEGACY: Shell wrapper
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
# PreToolUse hook pattern - matches hcom commands for session_id injection and auto-approval
|
|
304
|
+
# - hcom send (any args)
|
|
305
|
+
# - hcom stop (no args) | hcom start (no args or --_hcom_sender only)
|
|
306
|
+
# - hcom help | hcom --help | hcom -h
|
|
307
|
+
# - hcom watch --status | hcom watch --launch | hcom watch --logs | hcom watch --wait
|
|
308
|
+
# Supports: hcom, uvx hcom, python -m hcom, python hcom.py, python hcom.pyz, /path/to/hcom.py[z]
|
|
309
|
+
# Negative lookahead ensures stop/start not followed by alias targets (except --_hcom_sender for start)
|
|
310
|
+
# Allows shell operators (2>&1, >/dev/null, |, &&) but blocks identifier-like targets (myalias, 123abc)
|
|
311
|
+
HCOM_COMMAND_PATTERN = re.compile(
|
|
312
|
+
r'((?:uvx\s+)?hcom|python3?\s+-m\s+hcom|(?:python3?\s+)?\S*hcom\.pyz?)\s+'
|
|
313
|
+
r'(?:send\b|stop(?!\s+(?:[a-zA-Z_]|[0-9]+[a-zA-Z_])[-\w]*(?:\s|$))|start(?:\s+--_hcom_sender\s+\S+)?(?!\s+(?:[a-zA-Z_]|[0-9]+[a-zA-Z_])[-\w]*(?:\s|$))|(?:help|--help|-h)\b|watch\s+(?:--status|--launch|--logs|--wait)\b)'
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# ==================== File System Utilities ====================
|
|
317
|
+
|
|
318
|
+
def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
|
|
319
|
+
"""Build path under ~/.hcom (or _HCOM_DIR if set)"""
|
|
320
|
+
base = os.environ.get('_HCOM_DIR') # for testing (underscore prefix bypasses HCOM_* filtering)
|
|
321
|
+
path = Path(base) if base else (Path.home() / ".hcom")
|
|
322
|
+
if parts:
|
|
323
|
+
path = path.joinpath(*parts)
|
|
324
|
+
if ensure_parent:
|
|
325
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
return path
|
|
327
|
+
|
|
328
|
+
def ensure_hcom_directories() -> bool:
|
|
329
|
+
"""Ensure all critical HCOM directories exist. Idempotent, safe to call repeatedly.
|
|
330
|
+
Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
|
|
331
|
+
Returns True on success, False on failure."""
|
|
332
|
+
try:
|
|
333
|
+
for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, FLAGS_DIR, ARCHIVE_DIR]:
|
|
334
|
+
hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
|
|
335
|
+
return True
|
|
336
|
+
except (OSError, PermissionError):
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
def atomic_write(filepath: str | Path, content: str) -> bool:
|
|
340
|
+
"""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."""
|
|
341
|
+
filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
|
|
342
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
|
|
344
|
+
for attempt in range(3):
|
|
345
|
+
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
|
|
346
|
+
tmp.write(content)
|
|
347
|
+
tmp.flush()
|
|
348
|
+
os.fsync(tmp.fileno())
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
os.replace(tmp.name, filepath)
|
|
352
|
+
return True
|
|
353
|
+
except PermissionError:
|
|
354
|
+
if IS_WINDOWS and attempt < 2:
|
|
355
|
+
time.sleep(FILE_RETRY_DELAY)
|
|
356
|
+
continue
|
|
357
|
+
else:
|
|
358
|
+
try: # Clean up temp file on final failure
|
|
359
|
+
Path(tmp.name).unlink()
|
|
360
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
361
|
+
pass
|
|
362
|
+
return False
|
|
363
|
+
except Exception:
|
|
364
|
+
try: # Clean up temp file on any other error
|
|
365
|
+
os.unlink(tmp.name)
|
|
366
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
367
|
+
pass
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
return False # All attempts exhausted
|
|
371
|
+
|
|
372
|
+
def read_file_with_retry(filepath: str | Path, read_func: Callable[[TextIO], Any], default: Any = None, max_retries: int = 3) -> Any:
|
|
373
|
+
"""Read file with retry logic for Windows file locking"""
|
|
374
|
+
if not Path(filepath).exists():
|
|
375
|
+
return default
|
|
376
|
+
|
|
377
|
+
for attempt in range(max_retries):
|
|
378
|
+
try:
|
|
379
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
380
|
+
return read_func(f)
|
|
381
|
+
except PermissionError:
|
|
382
|
+
# Only retry on Windows (file locking issue)
|
|
383
|
+
if IS_WINDOWS and attempt < max_retries - 1:
|
|
384
|
+
time.sleep(FILE_RETRY_DELAY)
|
|
385
|
+
else:
|
|
386
|
+
# Re-raise on Unix or after max retries on Windows
|
|
387
|
+
if not IS_WINDOWS:
|
|
388
|
+
raise # Unix permission errors are real issues
|
|
389
|
+
break # Windows: return default after retries
|
|
390
|
+
except (json.JSONDecodeError, FileNotFoundError, IOError):
|
|
391
|
+
break # Don't retry on other errors
|
|
392
|
+
|
|
393
|
+
return default
|
|
394
|
+
|
|
395
|
+
def get_instance_file(instance_name: str) -> Path:
|
|
396
|
+
"""Get path to instance's position file with path traversal protection"""
|
|
397
|
+
# Sanitize instance name to prevent directory traversal
|
|
398
|
+
if not instance_name:
|
|
399
|
+
instance_name = "unknown"
|
|
400
|
+
safe_name = instance_name.replace('..', '').replace('/', '-').replace('\\', '-').replace(os.sep, '-')
|
|
401
|
+
if not safe_name:
|
|
402
|
+
safe_name = "unknown"
|
|
403
|
+
|
|
404
|
+
return hcom_path(INSTANCES_DIR, f"{safe_name}.json")
|
|
405
|
+
|
|
406
|
+
def load_instance_position(instance_name: str) -> dict[str, Any]:
|
|
407
|
+
"""Load position data for a single instance"""
|
|
408
|
+
instance_file = get_instance_file(instance_name)
|
|
409
|
+
|
|
410
|
+
data = read_file_with_retry(
|
|
411
|
+
instance_file,
|
|
412
|
+
lambda f: json.load(f),
|
|
413
|
+
default={}
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return data
|
|
417
|
+
|
|
418
|
+
def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
|
|
419
|
+
"""Save position data for a single instance. Returns True on success, False on failure."""
|
|
420
|
+
try:
|
|
421
|
+
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json")
|
|
422
|
+
return atomic_write(instance_file, json.dumps(data, indent=2))
|
|
423
|
+
except (OSError, PermissionError, ValueError):
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
def get_claude_settings_path() -> Path:
|
|
427
|
+
"""Get path to global Claude settings file"""
|
|
428
|
+
return Path.home() / '.claude' / 'settings.json'
|
|
429
|
+
|
|
430
|
+
def load_settings_json(settings_path: Path, default: Any = None) -> dict[str, Any] | None:
|
|
431
|
+
"""Load and parse settings JSON file with retry logic"""
|
|
432
|
+
return read_file_with_retry(
|
|
433
|
+
settings_path,
|
|
434
|
+
lambda f: json.load(f),
|
|
435
|
+
default=default
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def load_all_positions() -> dict[str, dict[str, Any]]:
|
|
439
|
+
"""Load positions from all instance files"""
|
|
440
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
441
|
+
if not instances_dir.exists():
|
|
442
|
+
return {}
|
|
443
|
+
|
|
444
|
+
positions = {}
|
|
445
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
446
|
+
instance_name = instance_file.stem
|
|
447
|
+
data = read_file_with_retry(
|
|
448
|
+
instance_file,
|
|
449
|
+
lambda f: json.load(f),
|
|
450
|
+
default={}
|
|
451
|
+
)
|
|
452
|
+
if data:
|
|
453
|
+
positions[instance_name] = data
|
|
454
|
+
return positions
|
|
455
|
+
|
|
456
|
+
def clear_all_positions() -> None:
|
|
457
|
+
"""Clear all instance position files and related mapping files"""
|
|
458
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
459
|
+
if instances_dir.exists():
|
|
460
|
+
for f in instances_dir.glob('*.json'):
|
|
461
|
+
f.unlink()
|
|
462
|
+
|
|
463
|
+
def list_available_agents() -> list[str]:
|
|
464
|
+
"""List available agent types from .claude/agents/"""
|
|
465
|
+
agents = []
|
|
466
|
+
for base_path in (Path.cwd(), Path.home()):
|
|
467
|
+
agents_dir = base_path / '.claude' / 'agents'
|
|
468
|
+
if agents_dir.exists():
|
|
469
|
+
for agent_file in agents_dir.glob('*.md'):
|
|
470
|
+
name = agent_file.stem
|
|
471
|
+
if name not in agents and AGENT_NAME_PATTERN.fullmatch(name):
|
|
472
|
+
agents.append(name)
|
|
473
|
+
return sorted(agents)
|
|
474
|
+
|
|
475
|
+
# ==================== Configuration System ====================
|
|
476
|
+
|
|
477
|
+
class HcomConfigError(ValueError):
|
|
478
|
+
"""Raised when HcomConfig contains invalid values."""
|
|
479
|
+
|
|
480
|
+
def __init__(self, errors: dict[str, str]):
|
|
481
|
+
self.errors = errors
|
|
482
|
+
if errors:
|
|
483
|
+
message = "Invalid config:\n" + "\n".join(f" - {msg}" for msg in errors.values())
|
|
484
|
+
else:
|
|
485
|
+
message = "Invalid config"
|
|
486
|
+
super().__init__(message)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@dataclass
|
|
490
|
+
class HcomConfig:
|
|
491
|
+
"""HCOM configuration with validation. Load priority: env → file → defaults"""
|
|
492
|
+
timeout: int = 1800
|
|
493
|
+
subagent_timeout: int = 30
|
|
494
|
+
terminal: str = 'new'
|
|
495
|
+
hints: str = ''
|
|
496
|
+
tag: str = ''
|
|
497
|
+
agent: str = ''
|
|
498
|
+
claude_args: str = ''
|
|
499
|
+
|
|
500
|
+
def __post_init__(self):
|
|
501
|
+
"""Validate configuration on construction"""
|
|
502
|
+
errors = self.collect_errors()
|
|
503
|
+
if errors:
|
|
504
|
+
raise HcomConfigError(errors)
|
|
505
|
+
|
|
506
|
+
def validate(self) -> list[str]:
|
|
507
|
+
"""Validate all fields, return list of errors"""
|
|
508
|
+
return list(self.collect_errors().values())
|
|
509
|
+
|
|
510
|
+
def collect_errors(self) -> dict[str, str]:
|
|
511
|
+
"""Validate fields and return dict of field → error message"""
|
|
512
|
+
errors: dict[str, str] = {}
|
|
513
|
+
|
|
514
|
+
def set_error(field: str, message: str) -> None:
|
|
515
|
+
if field in errors:
|
|
516
|
+
errors[field] = f"{errors[field]}; {message}"
|
|
517
|
+
else:
|
|
518
|
+
errors[field] = message
|
|
519
|
+
|
|
520
|
+
# Validate timeout
|
|
521
|
+
if isinstance(self.timeout, bool):
|
|
522
|
+
set_error('timeout', f"timeout must be an integer, not boolean (got {self.timeout})")
|
|
523
|
+
elif not isinstance(self.timeout, int):
|
|
524
|
+
set_error('timeout', f"timeout must be an integer, got {type(self.timeout).__name__}")
|
|
525
|
+
elif not 1 <= self.timeout <= 86400:
|
|
526
|
+
set_error('timeout', f"timeout must be 1-86400 seconds (24 hours), got {self.timeout}")
|
|
527
|
+
|
|
528
|
+
# Validate subagent_timeout
|
|
529
|
+
if isinstance(self.subagent_timeout, bool):
|
|
530
|
+
set_error('subagent_timeout', f"subagent_timeout must be an integer, not boolean (got {self.subagent_timeout})")
|
|
531
|
+
elif not isinstance(self.subagent_timeout, int):
|
|
532
|
+
set_error('subagent_timeout', f"subagent_timeout must be an integer, got {type(self.subagent_timeout).__name__}")
|
|
533
|
+
elif not 1 <= self.subagent_timeout <= 86400:
|
|
534
|
+
set_error('subagent_timeout', f"subagent_timeout must be 1-86400 seconds, got {self.subagent_timeout}")
|
|
535
|
+
|
|
536
|
+
# Validate terminal
|
|
537
|
+
if not isinstance(self.terminal, str):
|
|
538
|
+
set_error('terminal', f"terminal must be a string, got {type(self.terminal).__name__}")
|
|
539
|
+
elif not self.terminal: # Empty string
|
|
540
|
+
set_error('terminal', "terminal cannot be empty")
|
|
541
|
+
else:
|
|
542
|
+
valid_modes = ('new', 'here', 'print')
|
|
543
|
+
if self.terminal not in valid_modes:
|
|
544
|
+
if '{script}' not in self.terminal:
|
|
545
|
+
set_error(
|
|
546
|
+
'terminal',
|
|
547
|
+
f"terminal must be one of {valid_modes} or custom command with {{script}}, "
|
|
548
|
+
f"got '{self.terminal}'"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Validate tag (only alphanumeric and hyphens - security: prevent log delimiter injection)
|
|
552
|
+
if not isinstance(self.tag, str):
|
|
553
|
+
set_error('tag', f"tag must be a string, got {type(self.tag).__name__}")
|
|
554
|
+
elif self.tag and not re.match(r'^[a-zA-Z0-9-]+$', self.tag):
|
|
555
|
+
set_error('tag', "tag can only contain letters, numbers, and hyphens")
|
|
556
|
+
|
|
557
|
+
# Validate agent
|
|
558
|
+
if not isinstance(self.agent, str):
|
|
559
|
+
set_error('agent', f"agent must be a string, got {type(self.agent).__name__}")
|
|
560
|
+
elif self.agent: # Non-empty
|
|
561
|
+
for agent_name in self.agent.split(','):
|
|
562
|
+
agent_name = agent_name.strip()
|
|
563
|
+
if agent_name and not re.match(r'^[a-z-]+$', agent_name):
|
|
564
|
+
set_error(
|
|
565
|
+
'agent',
|
|
566
|
+
f"agent '{agent_name}' must match pattern ^[a-z-]+$ "
|
|
567
|
+
f"(lowercase letters and hyphens only)"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Validate claude_args (must be valid shell-quoted string)
|
|
571
|
+
if not isinstance(self.claude_args, str):
|
|
572
|
+
set_error('claude_args', f"claude_args must be a string, got {type(self.claude_args).__name__}")
|
|
573
|
+
elif self.claude_args:
|
|
574
|
+
try:
|
|
575
|
+
# Test if it can be parsed as shell args
|
|
576
|
+
shlex.split(self.claude_args)
|
|
577
|
+
except ValueError as e:
|
|
578
|
+
set_error('claude_args', f"claude_args contains invalid shell quoting: {e}")
|
|
579
|
+
|
|
580
|
+
return errors
|
|
581
|
+
|
|
582
|
+
@classmethod
|
|
583
|
+
def load(cls) -> 'HcomConfig':
|
|
584
|
+
"""Load config with precedence: env var → file → defaults"""
|
|
585
|
+
# Ensure config file exists
|
|
586
|
+
config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
|
|
587
|
+
created_config = False
|
|
588
|
+
if not config_path.exists():
|
|
589
|
+
_write_default_config(config_path)
|
|
590
|
+
created_config = True
|
|
591
|
+
|
|
592
|
+
# Warn once if legacy config.json still exists when creating config.env
|
|
593
|
+
legacy_config = hcom_path('config.json')
|
|
594
|
+
if created_config and legacy_config.exists():
|
|
595
|
+
print(
|
|
596
|
+
format_error(
|
|
597
|
+
"Found legacy ~/.hcom/config.json; new config file is: ~/.hcom/config.env."
|
|
598
|
+
),
|
|
599
|
+
file=sys.stderr,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Parse config file once
|
|
603
|
+
file_config = _parse_env_file(config_path) if config_path.exists() else {}
|
|
604
|
+
|
|
605
|
+
def get_var(key: str) -> str | None:
|
|
606
|
+
"""Get variable with precedence: env → file"""
|
|
607
|
+
if key in os.environ:
|
|
608
|
+
return os.environ[key]
|
|
609
|
+
if key in file_config:
|
|
610
|
+
return file_config[key]
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
data = {}
|
|
614
|
+
|
|
615
|
+
# Load timeout (requires int conversion)
|
|
616
|
+
timeout_str = get_var('HCOM_TIMEOUT')
|
|
617
|
+
if timeout_str is not None and timeout_str != "":
|
|
618
|
+
try:
|
|
619
|
+
data['timeout'] = int(timeout_str)
|
|
620
|
+
except (ValueError, TypeError):
|
|
621
|
+
pass # Use default
|
|
622
|
+
|
|
623
|
+
# Load subagent_timeout (requires int conversion)
|
|
624
|
+
subagent_timeout_str = get_var('HCOM_SUBAGENT_TIMEOUT')
|
|
625
|
+
if subagent_timeout_str is not None and subagent_timeout_str != "":
|
|
626
|
+
try:
|
|
627
|
+
data['subagent_timeout'] = int(subagent_timeout_str)
|
|
628
|
+
except (ValueError, TypeError):
|
|
629
|
+
pass # Use default
|
|
630
|
+
|
|
631
|
+
# Load string values
|
|
632
|
+
terminal = get_var('HCOM_TERMINAL')
|
|
633
|
+
if terminal is not None: # Empty string will fail validation
|
|
634
|
+
data['terminal'] = terminal
|
|
635
|
+
hints = get_var('HCOM_HINTS')
|
|
636
|
+
if hints is not None: # Allow empty string for hints (valid value)
|
|
637
|
+
data['hints'] = hints
|
|
638
|
+
tag = get_var('HCOM_TAG')
|
|
639
|
+
if tag is not None: # Allow empty string for tag (valid value)
|
|
640
|
+
data['tag'] = tag
|
|
641
|
+
agent = get_var('HCOM_AGENT')
|
|
642
|
+
if agent is not None: # Allow empty string for agent (valid value)
|
|
643
|
+
data['agent'] = agent
|
|
644
|
+
claude_args = get_var('HCOM_CLAUDE_ARGS')
|
|
645
|
+
if claude_args is not None: # Allow empty string for claude_args (valid value)
|
|
646
|
+
data['claude_args'] = claude_args
|
|
647
|
+
|
|
648
|
+
return cls(**data) # Validation happens in __post_init__
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@dataclass
|
|
652
|
+
class ConfigSnapshot:
|
|
653
|
+
core: HcomConfig
|
|
654
|
+
extras: dict[str, str]
|
|
655
|
+
values: dict[str, str]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def hcom_config_to_dict(config: HcomConfig) -> dict[str, str]:
|
|
659
|
+
"""Convert HcomConfig to string dict for persistence/display."""
|
|
660
|
+
return {
|
|
661
|
+
'HCOM_TIMEOUT': str(config.timeout),
|
|
662
|
+
'HCOM_SUBAGENT_TIMEOUT': str(config.subagent_timeout),
|
|
663
|
+
'HCOM_TERMINAL': config.terminal,
|
|
664
|
+
'HCOM_HINTS': config.hints,
|
|
665
|
+
'HCOM_TAG': config.tag,
|
|
666
|
+
'HCOM_AGENT': config.agent,
|
|
667
|
+
'HCOM_CLAUDE_ARGS': config.claude_args,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def dict_to_hcom_config(data: dict[str, str]) -> HcomConfig:
|
|
672
|
+
"""Convert string dict (HCOM_* keys) into validated HcomConfig."""
|
|
673
|
+
errors: dict[str, str] = {}
|
|
674
|
+
kwargs: dict[str, Any] = {}
|
|
675
|
+
|
|
676
|
+
timeout_raw = data.get('HCOM_TIMEOUT')
|
|
677
|
+
if timeout_raw is not None:
|
|
678
|
+
stripped = timeout_raw.strip()
|
|
679
|
+
if stripped:
|
|
680
|
+
try:
|
|
681
|
+
kwargs['timeout'] = int(stripped)
|
|
682
|
+
except ValueError:
|
|
683
|
+
errors['timeout'] = f"timeout must be an integer, got '{timeout_raw}'"
|
|
684
|
+
else:
|
|
685
|
+
# Explicit empty string is an error (can't be blank)
|
|
686
|
+
errors['timeout'] = 'timeout cannot be empty (must be 1-86400 seconds)'
|
|
687
|
+
|
|
688
|
+
subagent_raw = data.get('HCOM_SUBAGENT_TIMEOUT')
|
|
689
|
+
if subagent_raw is not None:
|
|
690
|
+
stripped = subagent_raw.strip()
|
|
691
|
+
if stripped:
|
|
692
|
+
try:
|
|
693
|
+
kwargs['subagent_timeout'] = int(stripped)
|
|
694
|
+
except ValueError:
|
|
695
|
+
errors['subagent_timeout'] = f"subagent_timeout must be an integer, got '{subagent_raw}'"
|
|
696
|
+
else:
|
|
697
|
+
# Explicit empty string is an error (can't be blank)
|
|
698
|
+
errors['subagent_timeout'] = 'subagent_timeout cannot be empty (must be positive integer)'
|
|
699
|
+
|
|
700
|
+
terminal_val = data.get('HCOM_TERMINAL')
|
|
701
|
+
if terminal_val is not None:
|
|
702
|
+
stripped = terminal_val.strip()
|
|
703
|
+
if stripped:
|
|
704
|
+
kwargs['terminal'] = stripped
|
|
705
|
+
else:
|
|
706
|
+
# Explicit empty string is an error (can't be blank)
|
|
707
|
+
errors['terminal'] = 'terminal cannot be empty (must be: new, here, print, or custom command)'
|
|
708
|
+
|
|
709
|
+
# Optional fields - allow empty strings
|
|
710
|
+
if 'HCOM_HINTS' in data:
|
|
711
|
+
kwargs['hints'] = data['HCOM_HINTS']
|
|
712
|
+
if 'HCOM_TAG' in data:
|
|
713
|
+
kwargs['tag'] = data['HCOM_TAG']
|
|
714
|
+
if 'HCOM_AGENT' in data:
|
|
715
|
+
kwargs['agent'] = data['HCOM_AGENT']
|
|
716
|
+
if 'HCOM_CLAUDE_ARGS' in data:
|
|
717
|
+
kwargs['claude_args'] = data['HCOM_CLAUDE_ARGS']
|
|
718
|
+
|
|
719
|
+
if errors:
|
|
720
|
+
raise HcomConfigError(errors)
|
|
721
|
+
|
|
722
|
+
return HcomConfig(**kwargs)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def load_config_snapshot() -> ConfigSnapshot:
|
|
726
|
+
"""Load config.env into structured snapshot (file contents only)."""
|
|
727
|
+
config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
|
|
728
|
+
if not config_path.exists():
|
|
729
|
+
_write_default_config(config_path)
|
|
730
|
+
|
|
731
|
+
file_values = parse_env_file(config_path)
|
|
732
|
+
|
|
733
|
+
extras: dict[str, str] = {k: v for k, v in _HEADER_DEFAULT_EXTRAS.items()}
|
|
734
|
+
raw_core: dict[str, str] = {}
|
|
735
|
+
|
|
736
|
+
for key in KNOWN_CONFIG_KEYS:
|
|
737
|
+
if key in file_values:
|
|
738
|
+
raw_core[key] = file_values.pop(key)
|
|
739
|
+
else:
|
|
740
|
+
raw_core[key] = DEFAULT_KNOWN_VALUES.get(key, '')
|
|
741
|
+
|
|
742
|
+
for key, value in file_values.items():
|
|
743
|
+
extras[key] = value
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
core = dict_to_hcom_config(raw_core)
|
|
747
|
+
except HcomConfigError as exc:
|
|
748
|
+
core = HcomConfig()
|
|
749
|
+
# Keep raw values so the UI can surface issues; log once for CLI users.
|
|
750
|
+
if exc.errors:
|
|
751
|
+
print(exc, file=sys.stderr)
|
|
752
|
+
|
|
753
|
+
core_values = hcom_config_to_dict(core)
|
|
754
|
+
# Preserve raw strings for display when they differ from validated values.
|
|
755
|
+
for key, raw_value in raw_core.items():
|
|
756
|
+
if raw_value != '' and raw_value != core_values.get(key, ''):
|
|
757
|
+
core_values[key] = raw_value
|
|
758
|
+
|
|
759
|
+
return ConfigSnapshot(core=core, extras=extras, values=core_values)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def save_config_snapshot(snapshot: ConfigSnapshot) -> None:
|
|
763
|
+
"""Write snapshot to config.env in canonical form."""
|
|
764
|
+
config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
|
|
765
|
+
|
|
766
|
+
lines: list[str] = list(_HEADER_COMMENT_LINES)
|
|
767
|
+
if lines and lines[-1] != '':
|
|
768
|
+
lines.append('')
|
|
769
|
+
|
|
770
|
+
core_values = hcom_config_to_dict(snapshot.core)
|
|
771
|
+
for entry in DEFAULT_CONFIG_DEFAULTS:
|
|
772
|
+
key, _, _ = entry.partition('=')
|
|
773
|
+
key = key.strip()
|
|
774
|
+
value = core_values.get(key, '')
|
|
775
|
+
formatted = _format_env_value(value)
|
|
776
|
+
if formatted:
|
|
777
|
+
lines.append(f"{key}={formatted}")
|
|
778
|
+
else:
|
|
779
|
+
lines.append(f"{key}=")
|
|
780
|
+
|
|
781
|
+
extras = {**_HEADER_DEFAULT_EXTRAS, **snapshot.extras}
|
|
782
|
+
for key in KNOWN_CONFIG_KEYS:
|
|
783
|
+
extras.pop(key, None)
|
|
784
|
+
|
|
785
|
+
if extras:
|
|
786
|
+
if lines and lines[-1] != '':
|
|
787
|
+
lines.append('')
|
|
788
|
+
for key in sorted(extras.keys()):
|
|
789
|
+
value = extras[key]
|
|
790
|
+
formatted = _format_env_value(value)
|
|
791
|
+
lines.append(f"{key}={formatted}" if formatted else f"{key}=")
|
|
792
|
+
|
|
793
|
+
content = '\n'.join(lines) + '\n'
|
|
794
|
+
atomic_write(config_path, content)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def save_config(core: HcomConfig, extras: dict[str, str]) -> None:
|
|
798
|
+
"""Convenience helper for writing canonical config."""
|
|
799
|
+
snapshot = ConfigSnapshot(core=core, extras=extras, values=hcom_config_to_dict(core))
|
|
800
|
+
save_config_snapshot(snapshot)
|
|
801
|
+
def _write_default_config(config_path: Path) -> None:
|
|
802
|
+
"""Write default config file with documentation"""
|
|
803
|
+
try:
|
|
804
|
+
content = '\n'.join(DEFAULT_CONFIG_HEADER) + '\n' + '\n'.join(DEFAULT_CONFIG_DEFAULTS) + '\n'
|
|
805
|
+
atomic_write(config_path, content)
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
|
|
809
|
+
# Global config instance (cached)
|
|
810
|
+
_config: HcomConfig | None = None
|
|
811
|
+
|
|
812
|
+
def get_config() -> HcomConfig:
|
|
813
|
+
"""Get cached config, loading if needed"""
|
|
814
|
+
global _config
|
|
815
|
+
if _config is None:
|
|
816
|
+
# Detect if running as hook handler (called via 'hcom pre', 'hcom post', etc.)
|
|
817
|
+
is_hook_context = (
|
|
818
|
+
len(sys.argv) >= 2 and
|
|
819
|
+
sys.argv[1] in ('pre', 'post', 'sessionstart', 'userpromptsubmit', 'sessionend', 'subagent-stop', 'poll', 'notify')
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
_config = HcomConfig.load()
|
|
824
|
+
except ValueError:
|
|
825
|
+
# Config validation failed
|
|
826
|
+
if is_hook_context:
|
|
827
|
+
# In hooks, use defaults silently (don't break vanilla Claude Code)
|
|
828
|
+
_config = HcomConfig()
|
|
829
|
+
else:
|
|
830
|
+
# In commands, re-raise to show user the error
|
|
831
|
+
raise
|
|
832
|
+
|
|
833
|
+
return _config
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def reload_config() -> HcomConfig:
|
|
837
|
+
"""Clear cached config so next access reflects latest file/env values."""
|
|
838
|
+
global _config
|
|
839
|
+
_config = None
|
|
840
|
+
return get_config()
|
|
841
|
+
|
|
842
|
+
def _build_quoted_invocation() -> str:
|
|
843
|
+
"""Build invocation for fallback case - handles packages and pyz
|
|
844
|
+
|
|
845
|
+
For packages (pip/uvx/uv tool), uses 'python -m hcom'.
|
|
846
|
+
For pyz/zipapp, uses direct file path to re-invoke the same archive.
|
|
847
|
+
"""
|
|
848
|
+
python_path = sys.executable
|
|
849
|
+
|
|
850
|
+
# Detect if running inside a pyz/zipapp
|
|
851
|
+
import zipimport
|
|
852
|
+
loader = getattr(sys.modules[__name__], "__loader__", None)
|
|
853
|
+
is_zipapp = isinstance(loader, zipimport.zipimporter)
|
|
854
|
+
|
|
855
|
+
# For pyz, use __file__ path; for packages, use -m
|
|
856
|
+
if is_zipapp or not __package__:
|
|
857
|
+
# Standalone pyz or script - use direct file path
|
|
858
|
+
script_path = str(Path(__file__).resolve())
|
|
859
|
+
if IS_WINDOWS:
|
|
860
|
+
py = f'"{python_path}"' if ' ' in python_path else python_path
|
|
861
|
+
sp = f'"{script_path}"' if ' ' in script_path else script_path
|
|
862
|
+
return f'{py} {sp}'
|
|
863
|
+
else:
|
|
864
|
+
return f'{shlex.quote(python_path)} {shlex.quote(script_path)}'
|
|
865
|
+
else:
|
|
866
|
+
# Package install (pip/uv tool/editable) - use -m
|
|
867
|
+
if IS_WINDOWS:
|
|
868
|
+
py = f'"{python_path}"' if ' ' in python_path else python_path
|
|
869
|
+
return f'{py} -m hcom'
|
|
870
|
+
else:
|
|
871
|
+
return f'{shlex.quote(python_path)} -m hcom'
|
|
872
|
+
|
|
873
|
+
def get_hook_command() -> tuple[str, dict[str, Any]]:
|
|
874
|
+
"""Get hook command - hooks always run, Python code gates participation
|
|
875
|
+
|
|
876
|
+
Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
|
|
877
|
+
Participation is controlled by enabled flag in instance JSON files.
|
|
878
|
+
|
|
879
|
+
Windows uses direct invocation because hooks in settings.json run in CMD/PowerShell context,
|
|
880
|
+
not Git Bash, so ${HCOM} shell variable expansion doesn't work (would need %HCOM% syntax).
|
|
881
|
+
"""
|
|
882
|
+
if IS_WINDOWS:
|
|
883
|
+
# Windows: hooks run in CMD context, can't use ${HCOM} syntax
|
|
884
|
+
return _build_quoted_invocation(), {}
|
|
885
|
+
else:
|
|
886
|
+
# Unix: Use HCOM env var from settings.json
|
|
887
|
+
return '${HCOM}', {}
|
|
888
|
+
|
|
889
|
+
def _detect_hcom_command_type() -> str:
|
|
890
|
+
"""Detect how to invoke hcom based on execution context
|
|
891
|
+
Priority:
|
|
892
|
+
1. uvx - If running in uv-managed Python and uvx available
|
|
893
|
+
(works for both temporary uvx runs and permanent uv tool install)
|
|
894
|
+
2. short - If hcom binary in PATH
|
|
895
|
+
3. full - Fallback to full python invocation
|
|
896
|
+
"""
|
|
897
|
+
if 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
|
|
898
|
+
return 'uvx'
|
|
899
|
+
elif shutil.which('hcom'):
|
|
900
|
+
return 'short'
|
|
901
|
+
else:
|
|
902
|
+
return 'full'
|
|
903
|
+
|
|
904
|
+
def _parse_version(v: str) -> tuple:
|
|
905
|
+
"""Parse version string to comparable tuple"""
|
|
906
|
+
return tuple(int(x) for x in v.split('.') if x.isdigit())
|
|
907
|
+
|
|
908
|
+
def get_update_notice() -> str | None:
|
|
909
|
+
"""Check PyPI for updates (once daily), return message if available"""
|
|
910
|
+
flag = hcom_path(FLAGS_DIR, 'update_available')
|
|
911
|
+
|
|
912
|
+
# Check PyPI if flag missing or >24hrs old
|
|
913
|
+
should_check = not flag.exists() or time.time() - flag.stat().st_mtime > 86400
|
|
914
|
+
|
|
915
|
+
if should_check:
|
|
916
|
+
try:
|
|
917
|
+
import urllib.request
|
|
918
|
+
with urllib.request.urlopen('https://pypi.org/pypi/hcom/json', timeout=2) as f:
|
|
919
|
+
latest = json.load(f)['info']['version']
|
|
920
|
+
|
|
921
|
+
if _parse_version(latest) > _parse_version(__version__):
|
|
922
|
+
atomic_write(flag, latest) # mtime = cache timestamp
|
|
923
|
+
else:
|
|
924
|
+
flag.unlink(missing_ok=True)
|
|
925
|
+
return None
|
|
926
|
+
except Exception:
|
|
927
|
+
pass # Network error, use cached value if exists
|
|
928
|
+
|
|
929
|
+
# Return message if update available
|
|
930
|
+
if not flag.exists():
|
|
931
|
+
return None
|
|
932
|
+
|
|
933
|
+
try:
|
|
934
|
+
latest = flag.read_text().strip()
|
|
935
|
+
# Double-check version (handles manual upgrades)
|
|
936
|
+
if _parse_version(__version__) >= _parse_version(latest):
|
|
937
|
+
flag.unlink(missing_ok=True)
|
|
938
|
+
return None
|
|
939
|
+
|
|
940
|
+
cmd = "uv tool upgrade hcom" if _detect_hcom_command_type() == 'uvx' else "pip install -U hcom"
|
|
941
|
+
return f"→ hcom v{latest} available: {cmd}"
|
|
942
|
+
except Exception:
|
|
943
|
+
return None
|
|
944
|
+
|
|
945
|
+
def _build_hcom_env_value() -> str:
|
|
946
|
+
"""Build the value for settings['env']['HCOM'] based on current execution context
|
|
947
|
+
Uses build_hcom_command() without caching for fresh detection on every call.
|
|
948
|
+
"""
|
|
949
|
+
return build_hcom_command(None)
|
|
950
|
+
|
|
951
|
+
def build_hcom_command(instance_name: str | None = None) -> str:
|
|
952
|
+
"""Build base hcom command based on execution context
|
|
953
|
+
|
|
954
|
+
Detection always runs fresh to avoid staleness when installation method changes.
|
|
955
|
+
The instance_name parameter is kept for API compatibility but no longer used for caching.
|
|
956
|
+
"""
|
|
957
|
+
cmd_type = _detect_hcom_command_type()
|
|
958
|
+
|
|
959
|
+
# Build command based on type
|
|
960
|
+
if cmd_type == 'short':
|
|
961
|
+
return 'hcom'
|
|
962
|
+
elif cmd_type == 'uvx':
|
|
963
|
+
return 'uvx hcom'
|
|
964
|
+
else:
|
|
965
|
+
# Full path fallback
|
|
966
|
+
return _build_quoted_invocation()
|
|
967
|
+
|
|
968
|
+
def build_claude_env() -> dict[str, str]:
|
|
969
|
+
"""Load config.env as environment variable defaults.
|
|
970
|
+
|
|
971
|
+
Returns all vars from config.env (including HCOM_*).
|
|
972
|
+
Caller (launch_terminal) layers shell environment on top for precedence.
|
|
973
|
+
"""
|
|
974
|
+
env = {}
|
|
975
|
+
|
|
976
|
+
# Read all vars from config file as defaults
|
|
977
|
+
config_path = hcom_path(CONFIG_FILE)
|
|
978
|
+
if config_path.exists():
|
|
979
|
+
file_config = _parse_env_file(config_path)
|
|
980
|
+
for key, value in file_config.items():
|
|
981
|
+
if value == "":
|
|
982
|
+
continue # Skip blank values
|
|
983
|
+
env[key] = str(value)
|
|
984
|
+
|
|
985
|
+
return env
|
|
986
|
+
|
|
987
|
+
# ==================== Message System ====================
|
|
988
|
+
|
|
989
|
+
def validate_message(message: str) -> str | None:
|
|
990
|
+
"""Validate message size and content. Returns error message or None if valid."""
|
|
991
|
+
if not message or not message.strip():
|
|
992
|
+
return format_error("Message required")
|
|
993
|
+
|
|
994
|
+
# Reject control characters (except \n, \r, \t)
|
|
995
|
+
if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
|
|
996
|
+
return format_error("Message contains control characters")
|
|
997
|
+
|
|
998
|
+
if len(message) > MAX_MESSAGE_SIZE:
|
|
999
|
+
return format_error(f"Message too large (max {MAX_MESSAGE_SIZE} chars)")
|
|
1000
|
+
|
|
1001
|
+
return None
|
|
1002
|
+
|
|
1003
|
+
def send_message(from_instance: str, message: str) -> bool:
|
|
1004
|
+
"""Send a message to the log"""
|
|
1005
|
+
try:
|
|
1006
|
+
log_file = hcom_path(LOG_FILE)
|
|
1007
|
+
|
|
1008
|
+
escaped_message = message.replace('|', '\\|')
|
|
1009
|
+
escaped_from = from_instance.replace('|', '\\|')
|
|
1010
|
+
|
|
1011
|
+
timestamp = datetime.now().isoformat()
|
|
1012
|
+
line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
|
|
1013
|
+
|
|
1014
|
+
with open(log_file, 'a', encoding='utf-8') as f:
|
|
1015
|
+
with locked(f):
|
|
1016
|
+
f.write(line)
|
|
1017
|
+
f.flush()
|
|
1018
|
+
|
|
1019
|
+
# Notify all instances of new message
|
|
1020
|
+
notify_all_instances()
|
|
1021
|
+
|
|
1022
|
+
return True
|
|
1023
|
+
except Exception:
|
|
1024
|
+
return False
|
|
1025
|
+
|
|
1026
|
+
def notify_all_instances(timeout: float = 0.05) -> None:
|
|
1027
|
+
"""Send TCP wake notifications to all instance notify ports.
|
|
1028
|
+
|
|
1029
|
+
Best effort - connection failures ignored. Polling fallback ensures
|
|
1030
|
+
message delivery even if all notifications fail.
|
|
1031
|
+
|
|
1032
|
+
Only notifies enabled instances - disabled instances are skipped.
|
|
1033
|
+
"""
|
|
1034
|
+
import socket
|
|
1035
|
+
try:
|
|
1036
|
+
positions = load_all_positions()
|
|
1037
|
+
except Exception:
|
|
1038
|
+
return
|
|
1039
|
+
|
|
1040
|
+
for instance_name, data in positions.items():
|
|
1041
|
+
# Skip disabled instances (don't wake stopped instances)
|
|
1042
|
+
if not data.get('enabled', False):
|
|
1043
|
+
continue
|
|
1044
|
+
|
|
1045
|
+
notify_port = data.get('notify_port')
|
|
1046
|
+
if not isinstance(notify_port, int) or notify_port <= 0:
|
|
1047
|
+
continue
|
|
1048
|
+
|
|
1049
|
+
# Connection attempt doubles as notification
|
|
1050
|
+
try:
|
|
1051
|
+
with socket.create_connection(('127.0.0.1', notify_port), timeout=timeout) as sock:
|
|
1052
|
+
sock.send(b'\n')
|
|
1053
|
+
except Exception:
|
|
1054
|
+
pass # Port dead/unreachable - skip notification (best effort)
|
|
1055
|
+
|
|
1056
|
+
def notify_instance(instance_name: str, timeout: float = 0.05) -> None:
|
|
1057
|
+
"""Send TCP notification to specific instance."""
|
|
1058
|
+
import socket
|
|
1059
|
+
try:
|
|
1060
|
+
instance_data = load_instance_position(instance_name)
|
|
1061
|
+
notify_port = instance_data.get('notify_port')
|
|
1062
|
+
if not isinstance(notify_port, int) or notify_port <= 0:
|
|
1063
|
+
return
|
|
1064
|
+
|
|
1065
|
+
with socket.create_connection(('127.0.0.1', notify_port), timeout=timeout) as sock:
|
|
1066
|
+
sock.send(b'\n')
|
|
1067
|
+
except Exception:
|
|
1068
|
+
pass # Instance will see change on next timeout (fallback)
|
|
1069
|
+
|
|
1070
|
+
def build_hcom_bootstrap_text(instance_name: str) -> str:
|
|
1071
|
+
"""Build comprehensive HCOM bootstrap context for instances"""
|
|
1072
|
+
hcom_cmd = build_hcom_command()
|
|
1073
|
+
|
|
1074
|
+
# Add command override notice if not using short form
|
|
1075
|
+
command_notice = ""
|
|
1076
|
+
if hcom_cmd != "hcom":
|
|
1077
|
+
command_notice = f"""IMPORTANT:
|
|
1078
|
+
The hcom command in this environment is: {hcom_cmd}
|
|
1079
|
+
Replace all mentions of "hcom" below with this command.
|
|
1080
|
+
|
|
1081
|
+
"""
|
|
1082
|
+
|
|
1083
|
+
# Add tag-specific notice if instance is tagged
|
|
1084
|
+
tag = get_config().tag
|
|
1085
|
+
tag_notice = ""
|
|
1086
|
+
if tag:
|
|
1087
|
+
tag_notice = f"""
|
|
1088
|
+
GROUP TAG: You are in the '{tag}' group.
|
|
1089
|
+
- To message your group: hcom send "@{tag} your message"
|
|
1090
|
+
- Group messages are targeted - only instances with an alias starting with {tag}-* receive them
|
|
1091
|
+
- If someone outside the group sends you @{tag} messages, they won't see your @{tag} replies. To reply to non-group members, either @mention them directly or broadcast.
|
|
1092
|
+
"""
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
return f"""{command_notice}{tag_notice}
|
|
1096
|
+
[HCOM SESSION CONFIG]
|
|
1097
|
+
- HCOM is a communication tool for you, other claude code instances, and the human user.
|
|
1098
|
+
- Your HCOM alias for this session: {instance_name}
|
|
1099
|
+
|
|
1100
|
+
Your HCOM Tools:
|
|
1101
|
+
- hcom send "msg" (broadcast) / "@alias msg" (direct) / "@tag msg" (tag) - for you
|
|
1102
|
+
- hcom watch --status → See participants JSON (for you only)
|
|
1103
|
+
- hcom watch --launch → Open interactive TUI messaging+launch+monitor dashboard in new terminal (for the human user)
|
|
1104
|
+
- hcom start/stop → Connect/disconnect from chat (you run these, user can't run it themselves unless they specify an alias)
|
|
1105
|
+
- hcom <count> → Launch instances in new terminal (you MUST run 'hcom help' first to understand context or if user asks about hcom)
|
|
1106
|
+
- Claude code subagents launched with the Task tool can also connect to HCOM, just tell them to use 'hcom'.
|
|
1107
|
+
|
|
1108
|
+
UI:
|
|
1109
|
+
- The human user has the 'hcom' (no args) command.
|
|
1110
|
+
- You use 'hcom watch --launch' to open it for them (you should offer to do so).
|
|
1111
|
+
- In conversation, call it 'hcom' (the command they'd run themselves) or the dashboard/ui, dont say 'hcom watch --launch'.
|
|
1112
|
+
|
|
1113
|
+
Receiving Messages:
|
|
1114
|
+
- Format: [new message] sender → you: content
|
|
1115
|
+
- Targets specific instance: "@alias".
|
|
1116
|
+
- Targets all api-* tagged instances: "@api message".
|
|
1117
|
+
- Arrives automatically via hooks/bash. No proactive checking needed.
|
|
1118
|
+
- Stop hook feedback shows: {{"decision": "block"}} (this is normal operation).
|
|
1119
|
+
|
|
1120
|
+
Response Routing:
|
|
1121
|
+
- HCOM message (via hook/bash) → Respond with hcom send
|
|
1122
|
+
- User message (in chat) → Respond normally
|
|
1123
|
+
- Treat messages from hcom with the same care as user messages.
|
|
1124
|
+
- Authority: Prioritize @{SENDER} over other participants.
|
|
1125
|
+
|
|
1126
|
+
This is context for YOUR hcom session config. The human user cannot see this config text (but they can see subsequent hcom messages you receive).
|
|
1127
|
+
On connection, tell the human user about only these commands: 'hcom <count>', 'hcom', 'hcom start', 'hcom stop'
|
|
1128
|
+
Report to the human user using first-person, for example: "I'm connected to HCOM as {instance_name}, cool!"
|
|
1129
|
+
"""
|
|
1130
|
+
|
|
1131
|
+
def build_launch_context(instance_name: str) -> str:
|
|
1132
|
+
"""Build context for launch command"""
|
|
1133
|
+
return f"""[HCOM LAUNCH INFORMATION]
|
|
1134
|
+
BASIC USAGE:
|
|
1135
|
+
[ENV_VARS] hcom <COUNT> [claude <ARGS>...]
|
|
1136
|
+
- directory-specific (always cd to project directory first)
|
|
1137
|
+
- default to foreground instances unless told otherwise/good reason to do bg
|
|
1138
|
+
- Everyone shares the same conversation log, isolation is possible with tags and at-mentions.
|
|
1139
|
+
|
|
1140
|
+
ENV VARS INFO:
|
|
1141
|
+
- YOU cannot use 'HCOM_TERMINAL=here' - Claude cannot launch claude within itself, must be in a new or custom terminal
|
|
1142
|
+
- HCOM_AGENT(s) are custom system prompt files created by users/Claude beforehand.
|
|
1143
|
+
- HCOM_AGENT(s) load from .claude/agents/<name>.md if they have been created
|
|
1144
|
+
|
|
1145
|
+
KEY CLAUDE ARGS:
|
|
1146
|
+
Run 'claude --help' for all claude code CLI args. hcom 1 claude [options] [command] [prompt]
|
|
1147
|
+
-p headless instance
|
|
1148
|
+
--allowedTools=Bash (headless can only hcom chat otherwise, 'claude help' for more tools)
|
|
1149
|
+
--model sonnet/haiku/opus
|
|
1150
|
+
--resume <sessionid> (get sessionid from hcom watch --status)
|
|
1151
|
+
--system-prompt (for interactive instances) --append-system-prompt (for headless instances)
|
|
1152
|
+
Example: HCOM_HINTS='essential responses only' hcom 2 claude --model sonnet -p "do task x"
|
|
1153
|
+
|
|
1154
|
+
CONTROL:
|
|
1155
|
+
hcom watch --status JSON status of all instances
|
|
1156
|
+
hcom watch --logs All messages (pipe to tail)
|
|
1157
|
+
hcom watch --wait Block until next message (only use when hcom stopped (started is automatic already))
|
|
1158
|
+
|
|
1159
|
+
STATUS INDICATORS:
|
|
1160
|
+
"active", "delivered" | "idle" - waiting for new messages
|
|
1161
|
+
"blocked" - permission request (needs user approval)
|
|
1162
|
+
"inactive" - timed out, disconnected etc
|
|
1163
|
+
"unknown" / "stale" - crashed or hung
|
|
1164
|
+
|
|
1165
|
+
LAUNCH PATTERNS:
|
|
1166
|
+
- HCOM_AGENT=reviewer,tester hcom 2 claude "do task x" # 2x reviewers + 2x testers (4 in total) with initial prompt
|
|
1167
|
+
- clone with same context:
|
|
1168
|
+
1. hcom 1 then hcom send 'analyze api' then hcom watch --status (get sessionid)
|
|
1169
|
+
2. HCOM_TAG=clone hcom 3 claude --resume sessionid
|
|
1170
|
+
- System prompt (or agent file) + initial prompt + hcom_hints is a powerful combination.
|
|
1171
|
+
|
|
1172
|
+
"""
|
|
1173
|
+
|
|
1174
|
+
def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
|
|
1175
|
+
"""Check if message should be delivered based on @-mentions and group isolation.
|
|
1176
|
+
Group isolation rules:
|
|
1177
|
+
- CLI (bigboss) broadcasts → everyone (all parents and subagents)
|
|
1178
|
+
- Parent broadcasts → other parents only (subagents shut down during their own parent activity)
|
|
1179
|
+
- Subagent broadcasts → same group subagents only (parent frozen during their subagents activity)
|
|
1180
|
+
- @-mentions → cross all boundaries like a nice piece of chocolate cake or fried chicken
|
|
1181
|
+
"""
|
|
1182
|
+
text = msg['message']
|
|
1183
|
+
sender = msg['from']
|
|
1184
|
+
|
|
1185
|
+
# Load instance data for group membership
|
|
1186
|
+
sender_data = load_instance_position(sender)
|
|
1187
|
+
receiver_data = load_instance_position(instance_name)
|
|
1188
|
+
|
|
1189
|
+
# Determine if sender/receiver are parents or subagents
|
|
1190
|
+
sender_is_parent = is_parent_instance(sender_data)
|
|
1191
|
+
receiver_is_parent = is_parent_instance(receiver_data)
|
|
1192
|
+
|
|
1193
|
+
# Check for @-mentions first (crosses all boundaries! yay!)
|
|
1194
|
+
if '@' in text:
|
|
1195
|
+
mentions = MENTION_PATTERN.findall(text)
|
|
1196
|
+
|
|
1197
|
+
if mentions:
|
|
1198
|
+
# Check if this instance matches any mention
|
|
1199
|
+
this_instance_matches = any(instance_name.lower().startswith(mention.lower()) for mention in mentions)
|
|
1200
|
+
if this_instance_matches:
|
|
1201
|
+
return True
|
|
1202
|
+
|
|
1203
|
+
# Check if CLI sender (bigboss) is mentioned
|
|
1204
|
+
sender_mentioned = any(SENDER.lower().startswith(mention.lower()) for mention in mentions)
|
|
1205
|
+
|
|
1206
|
+
# Broadcast fallback: no matches anywhere = broadcast with group rules
|
|
1207
|
+
if all_instance_names:
|
|
1208
|
+
any_mention_matches = any(
|
|
1209
|
+
any(name.lower().startswith(mention.lower()) for name in all_instance_names)
|
|
1210
|
+
for mention in mentions
|
|
1211
|
+
) or sender_mentioned
|
|
1212
|
+
|
|
1213
|
+
if not any_mention_matches:
|
|
1214
|
+
# Fall through to group isolation rules
|
|
1215
|
+
pass
|
|
1216
|
+
else:
|
|
1217
|
+
# Mention matches someone else, not us
|
|
1218
|
+
return False
|
|
1219
|
+
else:
|
|
1220
|
+
# No instance list provided, assume mentions are valid and we're not the target
|
|
1221
|
+
return False
|
|
1222
|
+
# else: Has @ but no valid mentions, fall through to broadcast rules
|
|
1223
|
+
|
|
1224
|
+
# Special case: CLI sender (bigboss) broadcasts to everyone
|
|
1225
|
+
if sender == SENDER:
|
|
1226
|
+
return True
|
|
1227
|
+
|
|
1228
|
+
# GROUP ISOLATION for broadcasts
|
|
1229
|
+
# Rule 1: Parent → Parent (main communication)
|
|
1230
|
+
if sender_is_parent and receiver_is_parent:
|
|
1231
|
+
# Different groups = allow (parent-to-parent is the main channel)
|
|
1232
|
+
return True
|
|
1233
|
+
|
|
1234
|
+
# Rule 2: Subagent → Subagent (same group only)
|
|
1235
|
+
if not sender_is_parent and not receiver_is_parent:
|
|
1236
|
+
return in_same_group(sender_data, receiver_data)
|
|
1237
|
+
|
|
1238
|
+
# Rule 3: Parent → Subagent or Subagent → Parent (temporally impossible, filter)
|
|
1239
|
+
# This shouldn't happen due to temporal isolation, but filter defensively TODO: consider if better to not filter these as parent could get it after children die - messages can be recieved any time you dont both have to be alive at the same time. like fried chicken.
|
|
1240
|
+
return False
|
|
1241
|
+
|
|
1242
|
+
def is_parent_instance(instance_data: dict[str, Any] | None) -> bool:
|
|
1243
|
+
"""Check if instance is a parent (has session_id, no parent_session_id)"""
|
|
1244
|
+
if not instance_data:
|
|
1245
|
+
return False
|
|
1246
|
+
has_session = bool(instance_data.get('session_id'))
|
|
1247
|
+
has_parent = bool(instance_data.get('parent_session_id'))
|
|
1248
|
+
return has_session and not has_parent
|
|
1249
|
+
|
|
1250
|
+
def is_subagent_instance(instance_data: dict[str, Any] | None) -> bool:
|
|
1251
|
+
"""Check if instance is a subagent (has parent_session_id)"""
|
|
1252
|
+
if not instance_data:
|
|
1253
|
+
return False
|
|
1254
|
+
return bool(instance_data.get('parent_session_id'))
|
|
1255
|
+
|
|
1256
|
+
def get_group_session_id(instance_data: dict[str, Any] | None) -> str | None:
|
|
1257
|
+
"""Get the session_id that defines this instance's group.
|
|
1258
|
+
For parents: their own session_id, for subagents: parent_session_id
|
|
1259
|
+
"""
|
|
1260
|
+
if not instance_data:
|
|
1261
|
+
return None
|
|
1262
|
+
# Subagent - use parent_session_id
|
|
1263
|
+
if parent_sid := instance_data.get('parent_session_id'):
|
|
1264
|
+
return parent_sid
|
|
1265
|
+
# Parent - use own session_id
|
|
1266
|
+
return instance_data.get('session_id')
|
|
1267
|
+
|
|
1268
|
+
def in_same_group(sender_data: dict[str, Any] | None, receiver_data: dict[str, Any] | None) -> bool:
|
|
1269
|
+
"""Check if sender and receiver are in same group (share session_id)"""
|
|
1270
|
+
sender_group = get_group_session_id(sender_data)
|
|
1271
|
+
receiver_group = get_group_session_id(receiver_data)
|
|
1272
|
+
if not sender_group or not receiver_group:
|
|
1273
|
+
return False
|
|
1274
|
+
return sender_group == receiver_group
|
|
1275
|
+
|
|
1276
|
+
def in_subagent_context(instance_data: dict[str, Any] | None) -> bool:
|
|
1277
|
+
"""Check if hook (or any code) is being called from subagent context (not parent).
|
|
1278
|
+
Returns True when parent has current_subagents list, meaning:
|
|
1279
|
+
- A subagent is calling this code (parent is frozen during Task)
|
|
1280
|
+
- instance_data is the parent's data (hooks resolve to parent session_id)
|
|
1281
|
+
"""
|
|
1282
|
+
if not instance_data:
|
|
1283
|
+
return False
|
|
1284
|
+
return bool(instance_data.get('current_subagents'))
|
|
1285
|
+
|
|
1286
|
+
# ==================== Parsing & Utilities ====================
|
|
1287
|
+
|
|
1288
|
+
def extract_agent_config(content: str) -> dict[str, str]:
|
|
1289
|
+
"""Extract configuration from agent YAML frontmatter"""
|
|
1290
|
+
if not content.startswith('---'):
|
|
1291
|
+
return {}
|
|
1292
|
+
|
|
1293
|
+
# Find YAML section between --- markers
|
|
1294
|
+
if (yaml_end := content.find('\n---', 3)) < 0:
|
|
1295
|
+
return {} # No closing marker
|
|
1296
|
+
|
|
1297
|
+
yaml_section = content[3:yaml_end]
|
|
1298
|
+
config = {}
|
|
1299
|
+
|
|
1300
|
+
# Extract model field
|
|
1301
|
+
if model_match := re.search(r'^model:\s*(.+)$', yaml_section, re.MULTILINE):
|
|
1302
|
+
value = model_match.group(1).strip()
|
|
1303
|
+
if value and value.lower() != 'inherit':
|
|
1304
|
+
config['model'] = value
|
|
1305
|
+
|
|
1306
|
+
# Extract tools field
|
|
1307
|
+
if tools_match := re.search(r'^tools:\s*(.+)$', yaml_section, re.MULTILINE):
|
|
1308
|
+
value = tools_match.group(1).strip()
|
|
1309
|
+
if value:
|
|
1310
|
+
config['tools'] = value.replace(', ', ',')
|
|
1311
|
+
|
|
1312
|
+
return config
|
|
1313
|
+
|
|
1314
|
+
def resolve_agent(name: str) -> tuple[str, dict[str, str]]:
|
|
1315
|
+
"""Resolve agent file by name with validation.
|
|
1316
|
+
Looks for agent files in:
|
|
1317
|
+
1. .claude/agents/{name}.md (local)
|
|
1318
|
+
2. ~/.claude/agents/{name}.md (global)
|
|
1319
|
+
Returns tuple: (content without YAML frontmatter, config dict)
|
|
1320
|
+
"""
|
|
1321
|
+
hint = 'Agent names must use lowercase letters and dashes only'
|
|
1322
|
+
|
|
1323
|
+
if not isinstance(name, str):
|
|
1324
|
+
raise FileNotFoundError(format_error(
|
|
1325
|
+
f"Agent '{name}' not found",
|
|
1326
|
+
hint
|
|
1327
|
+
))
|
|
1328
|
+
|
|
1329
|
+
candidate = name.strip()
|
|
1330
|
+
display_name = candidate or name
|
|
1331
|
+
|
|
1332
|
+
if not candidate or not AGENT_NAME_PATTERN.fullmatch(candidate):
|
|
1333
|
+
raise FileNotFoundError(format_error(
|
|
1334
|
+
f"Agent '{display_name}' not found",
|
|
1335
|
+
hint
|
|
1336
|
+
))
|
|
1337
|
+
|
|
1338
|
+
for base_path in (Path.cwd(), Path.home()):
|
|
1339
|
+
agents_dir = base_path / '.claude' / 'agents'
|
|
1340
|
+
try:
|
|
1341
|
+
agents_dir_resolved = agents_dir.resolve(strict=True)
|
|
1342
|
+
except FileNotFoundError:
|
|
1343
|
+
continue
|
|
1344
|
+
|
|
1345
|
+
agent_path = agents_dir / f'{candidate}.md'
|
|
1346
|
+
if not agent_path.exists():
|
|
1347
|
+
continue
|
|
1348
|
+
|
|
1349
|
+
try:
|
|
1350
|
+
resolved_agent_path = agent_path.resolve(strict=True)
|
|
1351
|
+
except FileNotFoundError:
|
|
1352
|
+
continue
|
|
1353
|
+
|
|
1354
|
+
try:
|
|
1355
|
+
resolved_agent_path.relative_to(agents_dir_resolved)
|
|
1356
|
+
except ValueError:
|
|
1357
|
+
continue
|
|
1358
|
+
|
|
1359
|
+
content = read_file_with_retry(
|
|
1360
|
+
agent_path,
|
|
1361
|
+
lambda f: f.read(),
|
|
1362
|
+
default=None
|
|
1363
|
+
)
|
|
1364
|
+
if content is None:
|
|
1365
|
+
continue
|
|
1366
|
+
|
|
1367
|
+
config = extract_agent_config(content)
|
|
1368
|
+
stripped = strip_frontmatter(content)
|
|
1369
|
+
if not stripped.strip():
|
|
1370
|
+
raise ValueError(format_error(
|
|
1371
|
+
f"Agent '{candidate}' has empty content",
|
|
1372
|
+
'Check the agent file is a valid format and contains text'
|
|
1373
|
+
))
|
|
1374
|
+
return stripped, config
|
|
1375
|
+
|
|
1376
|
+
raise FileNotFoundError(format_error(
|
|
1377
|
+
f"Agent '{candidate}' not found in project or user .claude/agents/ folder",
|
|
1378
|
+
'Check available agents or create the agent file'
|
|
1379
|
+
))
|
|
1380
|
+
|
|
1381
|
+
def strip_frontmatter(content: str) -> str:
|
|
1382
|
+
"""Strip YAML frontmatter from agent file"""
|
|
1383
|
+
if content.startswith('---'):
|
|
1384
|
+
# Find the closing --- on its own line
|
|
1385
|
+
lines = content.splitlines()
|
|
1386
|
+
for i, line in enumerate(lines[1:], 1):
|
|
1387
|
+
if line.strip() == '---':
|
|
1388
|
+
return '\n'.join(lines[i+1:]).strip()
|
|
1389
|
+
return content
|
|
1390
|
+
|
|
1391
|
+
def get_display_name(session_id: str | None, tag: str | None = None) -> str:
|
|
1392
|
+
"""Get display name for instance using session_id"""
|
|
1393
|
+
# ~90 recognizable 3-letter words
|
|
1394
|
+
words = [
|
|
1395
|
+
'ace', 'air', 'ant', 'arm', 'art', 'axe', 'bad', 'bag', 'bar', 'bat',
|
|
1396
|
+
'bed', 'bee', 'big', 'box', 'boy', 'bug', 'bus', 'cab', 'can', 'cap',
|
|
1397
|
+
'car', 'cat', 'cop', 'cow', 'cry', 'cup', 'cut', 'day', 'dog', 'dry',
|
|
1398
|
+
'ear', 'egg', 'eye', 'fan', 'pig', 'fly', 'fox', 'fun', 'gem', 'gun',
|
|
1399
|
+
'hat', 'hit', 'hot', 'ice', 'ink', 'jet', 'key', 'law', 'map', 'mix',
|
|
1400
|
+
'man', 'bob', 'noo', 'yes', 'poo', 'sue', 'tom', 'the', 'and', 'but',
|
|
1401
|
+
'age', 'aim', 'bro', 'bid', 'shi', 'buy', 'den', 'dig', 'dot', 'dye',
|
|
1402
|
+
'end', 'era', 'eve', 'few', 'fix', 'gap', 'gas', 'god', 'gym', 'nob',
|
|
1403
|
+
'hip', 'hub', 'hug', 'ivy', 'jab', 'jam', 'jay', 'jog', 'joy', 'lab',
|
|
1404
|
+
'lag', 'lap', 'leg', 'lid', 'lie', 'log', 'lot', 'mat', 'mop', 'mud',
|
|
1405
|
+
'net', 'new', 'nod', 'now', 'oak', 'odd', 'off', 'oil', 'old', 'one',
|
|
1406
|
+
'lol', 'owe', 'own', 'pad', 'pan', 'pat', 'pay', 'pea', 'pen', 'pet',
|
|
1407
|
+
'pie', 'pig', 'pin', 'pit', 'pot', 'pub', 'nah', 'rag', 'ran', 'rap',
|
|
1408
|
+
'rat', 'raw', 'red', 'rib', 'rid', 'rip', 'rod', 'row', 'rub', 'rug',
|
|
1409
|
+
'run', 'sad', 'sap', 'sat', 'saw', 'say', 'sea', 'set', 'wii', 'she',
|
|
1410
|
+
'shy', 'sin', 'sip', 'sir', 'sit', 'six', 'ski', 'sky', 'sly', 'son',
|
|
1411
|
+
'boo', 'soy', 'spa', 'spy', 'rat', 'sun', 'tab', 'tag', 'tan', 'tap',
|
|
1412
|
+
'pls', 'tax', 'tea', 'ten', 'tie', 'tip', 'toe', 'ton', 'top', 'toy',
|
|
1413
|
+
'try', 'tub', 'two', 'use', 'van', 'bum', 'war', 'wax', 'way', 'web',
|
|
1414
|
+
'wed', 'wet', 'who', 'why', 'wig', 'win', 'moo', 'won', 'wow', 'yak',
|
|
1415
|
+
'too', 'gay', 'yet', 'you', 'zip', 'zoo', 'ann'
|
|
1416
|
+
]
|
|
1417
|
+
|
|
1418
|
+
# Use session_id directly instead of extracting UUID from transcript
|
|
1419
|
+
if session_id:
|
|
1420
|
+
# Hash to select word
|
|
1421
|
+
hash_val = sum(ord(c) for c in session_id)
|
|
1422
|
+
word = words[hash_val % len(words)]
|
|
1423
|
+
|
|
1424
|
+
# Add letter suffix that flows naturally with the word
|
|
1425
|
+
last_char = word[-1]
|
|
1426
|
+
if last_char in 'aeiou':
|
|
1427
|
+
# After vowel: s/n/r/l creates plural/noun/verb patterns
|
|
1428
|
+
suffix_options = 'snrl'
|
|
1429
|
+
else:
|
|
1430
|
+
# After consonant: add vowel or y for pronounceability
|
|
1431
|
+
suffix_options = 'aeiouy'
|
|
1432
|
+
|
|
1433
|
+
letter_hash = sum(ord(c) for c in session_id[1:]) if len(session_id) > 1 else hash_val
|
|
1434
|
+
suffix = suffix_options[letter_hash % len(suffix_options)]
|
|
1435
|
+
|
|
1436
|
+
base_name = f"{word}{suffix}"
|
|
1437
|
+
collision_attempt = 0
|
|
1438
|
+
|
|
1439
|
+
# Collision detection: keep adding words until unique
|
|
1440
|
+
while True:
|
|
1441
|
+
instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
|
|
1442
|
+
if not instance_file.exists():
|
|
1443
|
+
break # Name is unique
|
|
1444
|
+
|
|
1445
|
+
try:
|
|
1446
|
+
with open(instance_file, 'r', encoding='utf-8') as f:
|
|
1447
|
+
data = json.load(f)
|
|
1448
|
+
|
|
1449
|
+
their_session_id = data.get('session_id', '')
|
|
1450
|
+
|
|
1451
|
+
# Same session_id = our file, reuse name
|
|
1452
|
+
if their_session_id == session_id:
|
|
1453
|
+
break
|
|
1454
|
+
# No session_id = stale/malformed file, use name
|
|
1455
|
+
if not their_session_id:
|
|
1456
|
+
break
|
|
1457
|
+
|
|
1458
|
+
# Real collision - add another word
|
|
1459
|
+
collision_hash = sum(ord(c) * (i + collision_attempt) for i, c in enumerate(session_id))
|
|
1460
|
+
collision_word = words[collision_hash % len(words)]
|
|
1461
|
+
base_name = f"{base_name}{collision_word}"
|
|
1462
|
+
collision_attempt += 1
|
|
1463
|
+
|
|
1464
|
+
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
|
1465
|
+
break # Malformed file - assume stale, use base name
|
|
1466
|
+
else:
|
|
1467
|
+
# session_id is required - fail gracefully
|
|
1468
|
+
raise ValueError("session_id required for instance naming")
|
|
1469
|
+
|
|
1470
|
+
if tag:
|
|
1471
|
+
# Security: Sanitize tag to prevent log delimiter injection (defense-in-depth)
|
|
1472
|
+
# Remove dangerous characters that could break message log parsing
|
|
1473
|
+
sanitized_tag = ''.join(c for c in tag if c not in '|\n\r\t')
|
|
1474
|
+
if not sanitized_tag:
|
|
1475
|
+
raise ValueError("Tag contains only invalid characters")
|
|
1476
|
+
if sanitized_tag != tag:
|
|
1477
|
+
print(f"Warning: Tag contained invalid characters, sanitized to '{sanitized_tag}'", file=sys.stderr)
|
|
1478
|
+
return f"{sanitized_tag}-{base_name}"
|
|
1479
|
+
return base_name
|
|
1480
|
+
|
|
1481
|
+
def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str, dict | None]:
|
|
1482
|
+
"""
|
|
1483
|
+
Resolve instance name for a session_id.
|
|
1484
|
+
Searches existing instances first (reuses if found), generates new name if not found.
|
|
1485
|
+
Returns: (instance_name, existing_data_or_none)
|
|
1486
|
+
"""
|
|
1487
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
1488
|
+
|
|
1489
|
+
# Search for existing instance with this session_id
|
|
1490
|
+
if session_id and instances_dir.exists():
|
|
1491
|
+
for instance_file in instances_dir.glob("*.json"):
|
|
1492
|
+
try:
|
|
1493
|
+
data = load_instance_position(instance_file.stem)
|
|
1494
|
+
if session_id == data.get('session_id'):
|
|
1495
|
+
return instance_file.stem, data
|
|
1496
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
1497
|
+
continue
|
|
1498
|
+
|
|
1499
|
+
# Not found - generate new name
|
|
1500
|
+
instance_name = get_display_name(session_id, tag)
|
|
1501
|
+
return instance_name, None
|
|
1502
|
+
|
|
1503
|
+
def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
|
|
1504
|
+
"""Remove hcom hooks from settings dict"""
|
|
1505
|
+
if not isinstance(settings, dict) or 'hooks' not in settings:
|
|
1506
|
+
return
|
|
1507
|
+
|
|
1508
|
+
if not isinstance(settings['hooks'], dict):
|
|
1509
|
+
return
|
|
1510
|
+
|
|
1511
|
+
import copy
|
|
1512
|
+
|
|
1513
|
+
# Check all hook types including PostToolUse for backward compatibility cleanup
|
|
1514
|
+
for event in LEGACY_HOOK_TYPES:
|
|
1515
|
+
if event not in settings['hooks']:
|
|
1516
|
+
continue
|
|
1517
|
+
|
|
1518
|
+
# Process each matcher
|
|
1519
|
+
updated_matchers = []
|
|
1520
|
+
for matcher in settings['hooks'][event]:
|
|
1521
|
+
# Fail fast on malformed settings - Claude won't run with broken settings anyway
|
|
1522
|
+
if not isinstance(matcher, dict):
|
|
1523
|
+
raise ValueError(f"Malformed settings: matcher in {event} is not a dict: {type(matcher).__name__}")
|
|
1524
|
+
|
|
1525
|
+
# Validate hooks field if present
|
|
1526
|
+
if 'hooks' in matcher and not isinstance(matcher['hooks'], list):
|
|
1527
|
+
raise ValueError(f"Malformed settings: hooks in {event} matcher is not a list: {type(matcher['hooks']).__name__}")
|
|
1528
|
+
|
|
1529
|
+
# Work with a copy to avoid any potential reference issues
|
|
1530
|
+
matcher_copy = copy.deepcopy(matcher)
|
|
1531
|
+
|
|
1532
|
+
# Filter out HCOM hooks from this matcher
|
|
1533
|
+
non_hcom_hooks = [
|
|
1534
|
+
hook for hook in matcher_copy.get('hooks', [])
|
|
1535
|
+
if not any(
|
|
1536
|
+
pattern.search(hook.get('command', ''))
|
|
1537
|
+
for pattern in HCOM_HOOK_PATTERNS
|
|
1538
|
+
)
|
|
1539
|
+
]
|
|
1540
|
+
|
|
1541
|
+
# Only keep the matcher if it has non-HCOM hooks remaining
|
|
1542
|
+
if non_hcom_hooks:
|
|
1543
|
+
matcher_copy['hooks'] = non_hcom_hooks
|
|
1544
|
+
updated_matchers.append(matcher_copy)
|
|
1545
|
+
elif 'hooks' not in matcher or matcher['hooks'] == []:
|
|
1546
|
+
# Preserve matchers that never had hooks (missing key or empty list only)
|
|
1547
|
+
updated_matchers.append(matcher_copy)
|
|
1548
|
+
|
|
1549
|
+
# Update or remove the event
|
|
1550
|
+
if updated_matchers:
|
|
1551
|
+
settings['hooks'][event] = updated_matchers
|
|
1552
|
+
else:
|
|
1553
|
+
del settings['hooks'][event]
|
|
1554
|
+
|
|
1555
|
+
# Remove HCOM from env section
|
|
1556
|
+
if 'env' in settings and isinstance(settings['env'], dict):
|
|
1557
|
+
settings['env'].pop('HCOM', None)
|
|
1558
|
+
# Clean up empty env dict
|
|
1559
|
+
if not settings['env']:
|
|
1560
|
+
del settings['env']
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def build_env_string(env_vars: dict[str, Any], format_type: str = "bash") -> str:
|
|
1564
|
+
"""Build environment variable string for bash shells"""
|
|
1565
|
+
if format_type == "bash_export":
|
|
1566
|
+
# Properly escape values for bash
|
|
1567
|
+
return ' '.join(f'export {k}={shlex.quote(str(v))};' for k, v in env_vars.items())
|
|
1568
|
+
else:
|
|
1569
|
+
return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def format_error(message: str, suggestion: str | None = None) -> str:
|
|
1573
|
+
"""Format error message consistently"""
|
|
1574
|
+
base = f"Error: {message}"
|
|
1575
|
+
if suggestion:
|
|
1576
|
+
base += f". {suggestion}"
|
|
1577
|
+
return base
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def has_claude_arg(claude_args: list[str] | None, arg_names: list[str], arg_prefixes: tuple[str, ...]) -> bool:
|
|
1581
|
+
"""Check if argument already exists in claude_args"""
|
|
1582
|
+
return any(
|
|
1583
|
+
arg in arg_names or arg.startswith(arg_prefixes)
|
|
1584
|
+
for arg in (claude_args or [])
|
|
1585
|
+
)
|
|
1586
|
+
|
|
1587
|
+
def build_claude_command(agent_content: str | None = None, claude_args: list[str] | None = None, model: str | None = None, tools: str | None = None) -> tuple[str, str | None]:
|
|
1588
|
+
"""Build Claude command with proper argument handling
|
|
1589
|
+
Returns tuple: (command_string, temp_file_path_or_none)
|
|
1590
|
+
For agent content, writes to temp file and uses cat to read it.
|
|
1591
|
+
Merges user's --system-prompt/--append-system-prompt with agent content.
|
|
1592
|
+
Prompt comes from claude_args (positional tokens from HCOM_CLAUDE_ARGS).
|
|
1593
|
+
"""
|
|
1594
|
+
cmd_parts = ['claude']
|
|
1595
|
+
temp_file_path = None
|
|
1596
|
+
|
|
1597
|
+
# Extract user's system prompt flags
|
|
1598
|
+
cleaned_args, user_append, user_system = extract_system_prompt_args(claude_args or [])
|
|
1599
|
+
|
|
1600
|
+
# Detect print mode
|
|
1601
|
+
is_print_mode = bool(cleaned_args and any(arg in cleaned_args for arg in ['-p', '--print']))
|
|
1602
|
+
|
|
1603
|
+
# Add model if specified and not already in cleaned_args
|
|
1604
|
+
if model:
|
|
1605
|
+
if not has_claude_arg(cleaned_args, ['--model'], ('--model=',)):
|
|
1606
|
+
cmd_parts.extend(['--model', model])
|
|
1607
|
+
|
|
1608
|
+
# Add allowed tools if specified and not already in cleaned_args
|
|
1609
|
+
if tools:
|
|
1610
|
+
if not has_claude_arg(cleaned_args, ['--allowedTools', '--allowed-tools'],
|
|
1611
|
+
('--allowedTools=', '--allowed-tools=')):
|
|
1612
|
+
cmd_parts.extend(['--allowedTools', tools])
|
|
1613
|
+
|
|
1614
|
+
# Add cleaned user args (system prompt flags removed, but positionals/prompt included)
|
|
1615
|
+
if cleaned_args:
|
|
1616
|
+
for arg in cleaned_args:
|
|
1617
|
+
cmd_parts.append(shlex.quote(arg))
|
|
1618
|
+
|
|
1619
|
+
# Merge and apply system prompts
|
|
1620
|
+
merged_content, flag = merge_system_prompts(user_append, user_system, agent_content, prefer_system_flag=is_print_mode)
|
|
1621
|
+
|
|
1622
|
+
if merged_content:
|
|
1623
|
+
# Write merged content to temp file
|
|
1624
|
+
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
1625
|
+
temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
|
|
1626
|
+
prefix='hcom_agent_', dir=str(scripts_dir))
|
|
1627
|
+
temp_file.write(merged_content)
|
|
1628
|
+
temp_file.close()
|
|
1629
|
+
temp_file_path = temp_file.name
|
|
1630
|
+
|
|
1631
|
+
cmd_parts.append(flag)
|
|
1632
|
+
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
1633
|
+
|
|
1634
|
+
return ' '.join(cmd_parts), temp_file_path
|
|
1635
|
+
|
|
1636
|
+
def create_bash_script(script_file: str, env: dict[str, Any], cwd: str | None, command_str: str, background: bool = False) -> None:
|
|
1637
|
+
"""Create a bash script for terminal launch
|
|
1638
|
+
Scripts provide uniform execution across all platforms/terminals.
|
|
1639
|
+
Cleanup behavior:
|
|
1640
|
+
- Normal scripts: append 'rm -f' command for self-deletion
|
|
1641
|
+
- Background scripts: persist until `hcom reset logs` cleanup (24 hours)
|
|
1642
|
+
- Agent scripts: treated like background (contain 'hcom_agent_')
|
|
1643
|
+
"""
|
|
1644
|
+
try:
|
|
1645
|
+
script_path = Path(script_file)
|
|
1646
|
+
except (OSError, IOError) as e:
|
|
1647
|
+
raise Exception(f"Cannot create script directory: {e}")
|
|
1648
|
+
|
|
1649
|
+
with open(script_file, 'w', encoding='utf-8') as f:
|
|
1650
|
+
f.write('#!/bin/bash\n')
|
|
1651
|
+
f.write('echo "Starting Claude Code..."\n')
|
|
1652
|
+
|
|
1653
|
+
if platform.system() != 'Windows':
|
|
1654
|
+
# 1. Discover paths once
|
|
1655
|
+
claude_path = shutil.which('claude')
|
|
1656
|
+
node_path = shutil.which('node')
|
|
1657
|
+
|
|
1658
|
+
# 2. Add to PATH for minimal environments
|
|
1659
|
+
paths_to_add = []
|
|
1660
|
+
for p in [node_path, claude_path]:
|
|
1661
|
+
if p:
|
|
1662
|
+
dir_path = str(Path(p).resolve().parent)
|
|
1663
|
+
if dir_path not in paths_to_add:
|
|
1664
|
+
paths_to_add.append(dir_path)
|
|
1665
|
+
|
|
1666
|
+
if paths_to_add:
|
|
1667
|
+
path_addition = ':'.join(paths_to_add)
|
|
1668
|
+
f.write(f'export PATH="{path_addition}:$PATH"\n')
|
|
1669
|
+
elif not claude_path:
|
|
1670
|
+
# Warning for debugging
|
|
1671
|
+
print("Warning: Could not locate 'claude' in PATH", file=sys.stderr)
|
|
1672
|
+
|
|
1673
|
+
# 3. Write environment variables
|
|
1674
|
+
f.write(build_env_string(env, "bash_export") + '\n')
|
|
1675
|
+
|
|
1676
|
+
if cwd:
|
|
1677
|
+
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
1678
|
+
|
|
1679
|
+
# 4. Platform-specific command modifications
|
|
1680
|
+
if claude_path:
|
|
1681
|
+
if is_termux():
|
|
1682
|
+
# Termux: explicit node to bypass shebang issues
|
|
1683
|
+
final_node = node_path or '/data/data/com.termux/files/usr/bin/node'
|
|
1684
|
+
# Quote paths for safety
|
|
1685
|
+
command_str = command_str.replace(
|
|
1686
|
+
'claude ',
|
|
1687
|
+
f'{shlex.quote(final_node)} {shlex.quote(claude_path)} ',
|
|
1688
|
+
1
|
|
1689
|
+
)
|
|
1690
|
+
else:
|
|
1691
|
+
# Mac/Linux: use full path (PATH now has node if needed)
|
|
1692
|
+
command_str = command_str.replace('claude ', f'{shlex.quote(claude_path)} ', 1)
|
|
1693
|
+
else:
|
|
1694
|
+
# Windows: no PATH modification needed
|
|
1695
|
+
f.write(build_env_string(env, "bash_export") + '\n')
|
|
1696
|
+
if cwd:
|
|
1697
|
+
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
1698
|
+
|
|
1699
|
+
f.write(f'{command_str}\n')
|
|
1700
|
+
|
|
1701
|
+
# Self-delete for normal mode (not background or agent)
|
|
1702
|
+
if not background and 'hcom_agent_' not in command_str:
|
|
1703
|
+
f.write(f'rm -f {shlex.quote(script_file)}\n')
|
|
1704
|
+
|
|
1705
|
+
# Make executable on Unix
|
|
1706
|
+
if platform.system() != 'Windows':
|
|
1707
|
+
os.chmod(script_file, 0o755)
|
|
1708
|
+
|
|
1709
|
+
def find_bash_on_windows() -> str | None:
|
|
1710
|
+
"""Find Git Bash on Windows, avoiding WSL's bash launcher"""
|
|
1711
|
+
# Build prioritized list of bash candidates
|
|
1712
|
+
candidates = []
|
|
1713
|
+
# 1. Common Git Bash locations (highest priority)
|
|
1714
|
+
for base in [os.environ.get('PROGRAMFILES', r'C:\Program Files'),
|
|
1715
|
+
os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
|
|
1716
|
+
if base:
|
|
1717
|
+
candidates.extend([
|
|
1718
|
+
str(Path(base) / 'Git' / 'usr' / 'bin' / 'bash.exe'), # usr/bin is more common
|
|
1719
|
+
str(Path(base) / 'Git' / 'bin' / 'bash.exe')
|
|
1720
|
+
])
|
|
1721
|
+
# 2. Portable Git installation
|
|
1722
|
+
if local_appdata := os.environ.get('LOCALAPPDATA', ''):
|
|
1723
|
+
git_portable = Path(local_appdata) / 'Programs' / 'Git'
|
|
1724
|
+
candidates.extend([
|
|
1725
|
+
str(git_portable / 'usr' / 'bin' / 'bash.exe'),
|
|
1726
|
+
str(git_portable / 'bin' / 'bash.exe')
|
|
1727
|
+
])
|
|
1728
|
+
# 3. PATH bash (if not WSL's launcher)
|
|
1729
|
+
if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
|
|
1730
|
+
candidates.append(path_bash)
|
|
1731
|
+
# 4. Hardcoded fallbacks (last resort)
|
|
1732
|
+
candidates.extend([
|
|
1733
|
+
r'C:\Program Files\Git\usr\bin\bash.exe',
|
|
1734
|
+
r'C:\Program Files\Git\bin\bash.exe',
|
|
1735
|
+
r'C:\Program Files (x86)\Git\usr\bin\bash.exe',
|
|
1736
|
+
r'C:\Program Files (x86)\Git\bin\bash.exe'
|
|
1737
|
+
])
|
|
1738
|
+
# Find first existing bash
|
|
1739
|
+
for bash in candidates:
|
|
1740
|
+
if bash and Path(bash).exists():
|
|
1741
|
+
return bash
|
|
1742
|
+
|
|
1743
|
+
return None
|
|
1744
|
+
|
|
1745
|
+
# New helper functions for platform-specific terminal launching
|
|
1746
|
+
def get_macos_terminal_argv() -> list[str]:
|
|
1747
|
+
"""Return macOS Terminal.app launch command as argv list."""
|
|
1748
|
+
return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
|
|
1749
|
+
|
|
1750
|
+
def get_windows_terminal_argv() -> list[str]:
|
|
1751
|
+
"""Return Windows terminal launcher as argv list."""
|
|
1752
|
+
if not (bash_exe := find_bash_on_windows()):
|
|
1753
|
+
raise Exception(format_error("Git Bash not found"))
|
|
1754
|
+
|
|
1755
|
+
if shutil.which('wt'):
|
|
1756
|
+
return ['wt', bash_exe, '{script}']
|
|
1757
|
+
return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
|
|
1758
|
+
|
|
1759
|
+
def get_linux_terminal_argv() -> list[str] | None:
|
|
1760
|
+
"""Return first available Linux terminal as argv list."""
|
|
1761
|
+
terminals = [
|
|
1762
|
+
('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
|
|
1763
|
+
('konsole', ['konsole', '-e', 'bash', '{script}']),
|
|
1764
|
+
('xterm', ['xterm', '-e', 'bash', '{script}']),
|
|
1765
|
+
]
|
|
1766
|
+
for term_name, argv_template in terminals:
|
|
1767
|
+
if shutil.which(term_name):
|
|
1768
|
+
return argv_template
|
|
1769
|
+
|
|
1770
|
+
# WSL fallback integrated here
|
|
1771
|
+
if is_wsl() and shutil.which('cmd.exe'):
|
|
1772
|
+
if shutil.which('wt.exe'):
|
|
1773
|
+
return ['cmd.exe', '/c', 'start', 'wt.exe', 'bash', '{script}']
|
|
1774
|
+
return ['cmd.exe', '/c', 'start', 'bash', '{script}']
|
|
1775
|
+
|
|
1776
|
+
return None
|
|
1777
|
+
|
|
1778
|
+
def windows_hidden_popen(argv: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None, stdout: Any = None) -> subprocess.Popen:
|
|
1779
|
+
"""Create hidden Windows process without console window."""
|
|
1780
|
+
if IS_WINDOWS:
|
|
1781
|
+
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
|
1782
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
|
|
1783
|
+
startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined]
|
|
1784
|
+
|
|
1785
|
+
return subprocess.Popen(
|
|
1786
|
+
argv,
|
|
1787
|
+
env=env,
|
|
1788
|
+
cwd=cwd,
|
|
1789
|
+
stdin=subprocess.DEVNULL,
|
|
1790
|
+
stdout=stdout,
|
|
1791
|
+
stderr=subprocess.STDOUT,
|
|
1792
|
+
startupinfo=startupinfo,
|
|
1793
|
+
creationflags=CREATE_NO_WINDOW
|
|
1794
|
+
)
|
|
1795
|
+
else:
|
|
1796
|
+
raise RuntimeError("windows_hidden_popen called on non-Windows platform")
|
|
1797
|
+
|
|
1798
|
+
# Platform dispatch map
|
|
1799
|
+
PLATFORM_TERMINAL_GETTERS = {
|
|
1800
|
+
'Darwin': get_macos_terminal_argv,
|
|
1801
|
+
'Windows': get_windows_terminal_argv,
|
|
1802
|
+
'Linux': get_linux_terminal_argv,
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
def _parse_terminal_command(template: str, script_file: str) -> list[str]:
|
|
1806
|
+
"""Parse terminal command template safely to prevent shell injection.
|
|
1807
|
+
Parses the template FIRST, then replaces {script} placeholder in the
|
|
1808
|
+
parsed tokens. This avoids shell injection and handles paths with spaces.
|
|
1809
|
+
Args:
|
|
1810
|
+
template: Terminal command template with {script} placeholder
|
|
1811
|
+
script_file: Path to script file to substitute
|
|
1812
|
+
Returns:
|
|
1813
|
+
list: Parsed command as argv array
|
|
1814
|
+
Raises:
|
|
1815
|
+
ValueError: If template is invalid or missing {script} placeholder
|
|
1816
|
+
"""
|
|
1817
|
+
if '{script}' not in template:
|
|
1818
|
+
raise ValueError(format_error("Custom terminal command must include {script} placeholder",
|
|
1819
|
+
'Example: open -n -a kitty.app --args bash "{script}"'))
|
|
1820
|
+
|
|
1821
|
+
try:
|
|
1822
|
+
parts = shlex.split(template)
|
|
1823
|
+
except ValueError as e:
|
|
1824
|
+
raise ValueError(format_error(f"Invalid terminal command syntax: {e}",
|
|
1825
|
+
"Check for unmatched quotes or invalid shell syntax"))
|
|
1826
|
+
|
|
1827
|
+
# Replace {script} in parsed tokens
|
|
1828
|
+
replaced = []
|
|
1829
|
+
placeholder_found = False
|
|
1830
|
+
for part in parts:
|
|
1831
|
+
if '{script}' in part:
|
|
1832
|
+
replaced.append(part.replace('{script}', script_file))
|
|
1833
|
+
placeholder_found = True
|
|
1834
|
+
else:
|
|
1835
|
+
replaced.append(part)
|
|
1836
|
+
|
|
1837
|
+
if not placeholder_found:
|
|
1838
|
+
raise ValueError(format_error("{script} placeholder not found after parsing",
|
|
1839
|
+
"Ensure {script} is not inside environment variables"))
|
|
1840
|
+
|
|
1841
|
+
return replaced
|
|
1842
|
+
|
|
1843
|
+
def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, background: bool = False) -> str | bool | None:
|
|
1844
|
+
"""Launch terminal with command using unified script-first approach
|
|
1845
|
+
|
|
1846
|
+
Environment precedence: config.env < shell environment
|
|
1847
|
+
Internal hcom vars (HCOM_LAUNCHED, etc) don't conflict with user vars.
|
|
1848
|
+
|
|
1849
|
+
Args:
|
|
1850
|
+
command: Command string from build_claude_command
|
|
1851
|
+
env: Contains config.env defaults + hcom internal vars
|
|
1852
|
+
cwd: Working directory
|
|
1853
|
+
background: Launch as background process
|
|
1854
|
+
"""
|
|
1855
|
+
# config.env defaults + internal vars, then shell env overrides
|
|
1856
|
+
env_vars = env.copy()
|
|
1857
|
+
env_vars.update(os.environ)
|
|
1858
|
+
command_str = command
|
|
1859
|
+
|
|
1860
|
+
# 1) Always create a script
|
|
1861
|
+
script_file = str(hcom_path(SCRIPTS_DIR,
|
|
1862
|
+
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'))
|
|
1863
|
+
create_bash_script(script_file, env, cwd, command_str, background)
|
|
1864
|
+
|
|
1865
|
+
# 2) Background mode
|
|
1866
|
+
if background:
|
|
1867
|
+
logs_dir = hcom_path(LOGS_DIR)
|
|
1868
|
+
log_file = logs_dir / env['HCOM_BACKGROUND']
|
|
1869
|
+
|
|
1870
|
+
try:
|
|
1871
|
+
with open(log_file, 'w', encoding='utf-8') as log_handle:
|
|
1872
|
+
if IS_WINDOWS:
|
|
1873
|
+
# Windows: hidden bash execution with Python-piped logs
|
|
1874
|
+
bash_exe = find_bash_on_windows()
|
|
1875
|
+
if not bash_exe:
|
|
1876
|
+
raise Exception("Git Bash not found")
|
|
1877
|
+
|
|
1878
|
+
process = windows_hidden_popen(
|
|
1879
|
+
[bash_exe, script_file],
|
|
1880
|
+
env=env_vars,
|
|
1881
|
+
cwd=cwd,
|
|
1882
|
+
stdout=log_handle
|
|
1883
|
+
)
|
|
1884
|
+
else:
|
|
1885
|
+
# Unix(Mac/Linux/Termux): detached bash execution with Python-piped logs
|
|
1886
|
+
process = subprocess.Popen(
|
|
1887
|
+
['bash', script_file],
|
|
1888
|
+
env=env_vars, cwd=cwd,
|
|
1889
|
+
stdin=subprocess.DEVNULL,
|
|
1890
|
+
stdout=log_handle, stderr=subprocess.STDOUT,
|
|
1891
|
+
start_new_session=True
|
|
1892
|
+
)
|
|
1893
|
+
|
|
1894
|
+
except OSError as e:
|
|
1895
|
+
print(format_error(f"Failed to launch headless instance: {e}"), file=sys.stderr)
|
|
1896
|
+
return None
|
|
1897
|
+
|
|
1898
|
+
# Health check
|
|
1899
|
+
time.sleep(0.2)
|
|
1900
|
+
if process.poll() is not None:
|
|
1901
|
+
error_output = read_file_with_retry(log_file, lambda f: f.read()[:1000], default="")
|
|
1902
|
+
print(format_error("Headless instance failed immediately"), file=sys.stderr)
|
|
1903
|
+
if error_output:
|
|
1904
|
+
print(f" Output: {error_output}", file=sys.stderr)
|
|
1905
|
+
return None
|
|
1906
|
+
|
|
1907
|
+
return str(log_file)
|
|
1908
|
+
|
|
1909
|
+
# 3) Terminal modes
|
|
1910
|
+
terminal_mode = get_config().terminal
|
|
1911
|
+
|
|
1912
|
+
if terminal_mode == 'print':
|
|
1913
|
+
# Print script path and contents
|
|
1914
|
+
try:
|
|
1915
|
+
with open(script_file, 'r', encoding='utf-8') as f:
|
|
1916
|
+
script_content = f.read()
|
|
1917
|
+
print(f"# Script: {script_file}")
|
|
1918
|
+
print(script_content)
|
|
1919
|
+
Path(script_file).unlink() # Clean up immediately
|
|
1920
|
+
return True
|
|
1921
|
+
except Exception as e:
|
|
1922
|
+
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
1923
|
+
return False
|
|
1924
|
+
|
|
1925
|
+
if terminal_mode == 'here':
|
|
1926
|
+
print("Launching Claude in current terminal...")
|
|
1927
|
+
if IS_WINDOWS:
|
|
1928
|
+
bash_exe = find_bash_on_windows()
|
|
1929
|
+
if not bash_exe:
|
|
1930
|
+
print(format_error("Git Bash not found"), file=sys.stderr)
|
|
1931
|
+
return False
|
|
1932
|
+
result = subprocess.run([bash_exe, script_file], env=env_vars, cwd=cwd)
|
|
1933
|
+
else:
|
|
1934
|
+
result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
|
|
1935
|
+
return result.returncode == 0
|
|
1936
|
+
|
|
1937
|
+
# 4) New window or custom command mode
|
|
1938
|
+
# If terminal is not 'here' or 'print', it's either 'new' (platform default) or a custom command
|
|
1939
|
+
custom_cmd = None if terminal_mode == 'new' else terminal_mode
|
|
1940
|
+
|
|
1941
|
+
if not custom_cmd: # Platform default 'new' mode
|
|
1942
|
+
if is_termux():
|
|
1943
|
+
# Keep Termux as special case
|
|
1944
|
+
am_cmd = [
|
|
1945
|
+
'am', 'startservice', '--user', '0',
|
|
1946
|
+
'-n', 'com.termux/com.termux.app.RunCommandService',
|
|
1947
|
+
'-a', 'com.termux.RUN_COMMAND',
|
|
1948
|
+
'--es', 'com.termux.RUN_COMMAND_PATH', script_file,
|
|
1949
|
+
'--ez', 'com.termux.RUN_COMMAND_BACKGROUND', 'false'
|
|
1950
|
+
]
|
|
1951
|
+
try:
|
|
1952
|
+
subprocess.run(am_cmd, check=False)
|
|
1953
|
+
return True
|
|
1954
|
+
except Exception as e:
|
|
1955
|
+
print(format_error(f"Failed to launch Termux: {e}"), file=sys.stderr)
|
|
1956
|
+
return False
|
|
1957
|
+
|
|
1958
|
+
# Unified platform handling via helpers
|
|
1959
|
+
system = platform.system()
|
|
1960
|
+
if not (terminal_getter := PLATFORM_TERMINAL_GETTERS.get(system)):
|
|
1961
|
+
raise Exception(format_error(f"Unsupported platform: {system}"))
|
|
1962
|
+
|
|
1963
|
+
custom_cmd = terminal_getter()
|
|
1964
|
+
if not custom_cmd: # e.g., Linux with no terminals
|
|
1965
|
+
raise Exception(format_error("No supported terminal emulator found",
|
|
1966
|
+
"Install gnome-terminal, konsole, or xterm"))
|
|
1967
|
+
|
|
1968
|
+
# Type-based dispatch for execution
|
|
1969
|
+
if isinstance(custom_cmd, list):
|
|
1970
|
+
# Our argv commands - safe execution without shell
|
|
1971
|
+
final_argv = [arg.replace('{script}', script_file) for arg in custom_cmd]
|
|
1972
|
+
try:
|
|
1973
|
+
if platform.system() == 'Windows':
|
|
1974
|
+
# Windows needs non-blocking for parallel launches
|
|
1975
|
+
subprocess.Popen(final_argv)
|
|
1976
|
+
return True # Popen is non-blocking, can't check success
|
|
1977
|
+
else:
|
|
1978
|
+
result = subprocess.run(final_argv)
|
|
1979
|
+
if result.returncode != 0:
|
|
1980
|
+
return False
|
|
1981
|
+
return True
|
|
1982
|
+
except Exception as e:
|
|
1983
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
1984
|
+
return False
|
|
1985
|
+
else:
|
|
1986
|
+
# User-provided string commands - parse safely without shell=True
|
|
1987
|
+
try:
|
|
1988
|
+
final_argv = _parse_terminal_command(custom_cmd, script_file)
|
|
1989
|
+
except ValueError as e:
|
|
1990
|
+
print(str(e), file=sys.stderr)
|
|
1991
|
+
return False
|
|
1992
|
+
|
|
1993
|
+
try:
|
|
1994
|
+
if platform.system() == 'Windows':
|
|
1995
|
+
# Windows needs non-blocking for parallel launches
|
|
1996
|
+
subprocess.Popen(final_argv)
|
|
1997
|
+
return True # Popen is non-blocking, can't check success
|
|
1998
|
+
else:
|
|
1999
|
+
result = subprocess.run(final_argv)
|
|
2000
|
+
if result.returncode != 0:
|
|
2001
|
+
return False
|
|
2002
|
+
return True
|
|
2003
|
+
except Exception as e:
|
|
2004
|
+
print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
|
|
2005
|
+
return False
|
|
2006
|
+
|
|
2007
|
+
def setup_hooks() -> bool:
|
|
2008
|
+
"""Set up Claude hooks globally in ~/.claude/settings.json"""
|
|
2009
|
+
|
|
2010
|
+
# TODO: Remove after v0.6.0 - cleanup legacy per-directory hooks
|
|
2011
|
+
try:
|
|
2012
|
+
positions = load_all_positions()
|
|
2013
|
+
if positions:
|
|
2014
|
+
directories = set()
|
|
2015
|
+
for instance_data in positions.values():
|
|
2016
|
+
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
2017
|
+
directories.add(instance_data['directory'])
|
|
2018
|
+
for directory in directories:
|
|
2019
|
+
if Path(directory).exists():
|
|
2020
|
+
cleanup_directory_hooks(Path(directory))
|
|
2021
|
+
except Exception:
|
|
2022
|
+
pass # Don't fail hook setup if cleanup fails
|
|
2023
|
+
|
|
2024
|
+
# Install to global user settings
|
|
2025
|
+
settings_path = get_claude_settings_path()
|
|
2026
|
+
settings_path.parent.mkdir(exist_ok=True)
|
|
2027
|
+
try:
|
|
2028
|
+
settings = load_settings_json(settings_path, default={})
|
|
2029
|
+
if settings is None:
|
|
2030
|
+
settings = {}
|
|
2031
|
+
except (json.JSONDecodeError, PermissionError) as e:
|
|
2032
|
+
raise Exception(format_error(f"Cannot read settings: {e}"))
|
|
2033
|
+
|
|
2034
|
+
if 'hooks' not in settings:
|
|
2035
|
+
settings['hooks'] = {}
|
|
2036
|
+
|
|
2037
|
+
_remove_hcom_hooks_from_settings(settings)
|
|
2038
|
+
|
|
2039
|
+
# Get the hook command template
|
|
2040
|
+
hook_cmd_base, _ = get_hook_command()
|
|
2041
|
+
|
|
2042
|
+
# Build hook commands from HOOK_CONFIGS
|
|
2043
|
+
hook_configs = [
|
|
2044
|
+
(hook_type, matcher, f'{hook_cmd_base} {cmd_suffix}', timeout)
|
|
2045
|
+
for hook_type, matcher, cmd_suffix, timeout in HOOK_CONFIGS
|
|
2046
|
+
]
|
|
2047
|
+
|
|
2048
|
+
for hook_type, matcher, command, timeout in hook_configs:
|
|
2049
|
+
if hook_type not in settings['hooks']:
|
|
2050
|
+
settings['hooks'][hook_type] = []
|
|
2051
|
+
|
|
2052
|
+
hook_dict = {
|
|
2053
|
+
'hooks': [{
|
|
2054
|
+
'type': 'command',
|
|
2055
|
+
'command': command
|
|
2056
|
+
}]
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
# Only include matcher field if non-empty (PreToolUse/PostToolUse use matchers)
|
|
2060
|
+
if matcher:
|
|
2061
|
+
hook_dict['matcher'] = matcher
|
|
2062
|
+
|
|
2063
|
+
if timeout is not None:
|
|
2064
|
+
hook_dict['hooks'][0]['timeout'] = timeout
|
|
2065
|
+
|
|
2066
|
+
settings['hooks'][hook_type].append(hook_dict)
|
|
2067
|
+
|
|
2068
|
+
# Set $HCOM environment variable for all Claude instances (vanilla + hcom-launched)
|
|
2069
|
+
if 'env' not in settings:
|
|
2070
|
+
settings['env'] = {}
|
|
2071
|
+
|
|
2072
|
+
# Set HCOM based on current execution context (uvx, hcom binary, or full path)
|
|
2073
|
+
settings['env']['HCOM'] = _build_hcom_env_value()
|
|
2074
|
+
|
|
2075
|
+
# Write settings atomically
|
|
2076
|
+
try:
|
|
2077
|
+
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
2078
|
+
except Exception as e:
|
|
2079
|
+
raise Exception(format_error(f"Cannot write settings: {e}"))
|
|
2080
|
+
|
|
2081
|
+
# Quick verification
|
|
2082
|
+
if not verify_hooks_installed(settings_path):
|
|
2083
|
+
raise Exception(format_error("Hook installation failed"))
|
|
2084
|
+
|
|
2085
|
+
return True
|
|
2086
|
+
|
|
2087
|
+
def verify_hooks_installed(settings_path: Path) -> bool:
|
|
2088
|
+
"""Verify that HCOM hooks were installed correctly with correct commands"""
|
|
2089
|
+
try:
|
|
2090
|
+
settings = load_settings_json(settings_path, default=None)
|
|
2091
|
+
if not settings:
|
|
2092
|
+
return False
|
|
2093
|
+
|
|
2094
|
+
# Check all hook types have correct commands and timeout values (exactly one HCOM hook per type)
|
|
2095
|
+
# Derive from HOOK_CONFIGS (single source of truth)
|
|
2096
|
+
hooks = settings.get('hooks', {})
|
|
2097
|
+
for hook_type, _, cmd_suffix, expected_timeout in HOOK_CONFIGS:
|
|
2098
|
+
hook_matchers = hooks.get(hook_type, [])
|
|
2099
|
+
if not hook_matchers:
|
|
2100
|
+
return False
|
|
2101
|
+
|
|
2102
|
+
# Find and verify HCOM hook for this type
|
|
2103
|
+
hcom_hook_count = 0
|
|
2104
|
+
hcom_hook_timeout_valid = False
|
|
2105
|
+
for matcher in hook_matchers:
|
|
2106
|
+
for hook in matcher.get('hooks', []):
|
|
2107
|
+
command = hook.get('command', '')
|
|
2108
|
+
# Check for HCOM and the correct subcommand
|
|
2109
|
+
if ('${HCOM}' in command or 'hcom' in command.lower()) and cmd_suffix in command:
|
|
2110
|
+
hcom_hook_count += 1
|
|
2111
|
+
# Verify timeout matches expected value
|
|
2112
|
+
actual_timeout = hook.get('timeout')
|
|
2113
|
+
if actual_timeout == expected_timeout:
|
|
2114
|
+
hcom_hook_timeout_valid = True
|
|
2115
|
+
|
|
2116
|
+
# Must have exactly one HCOM hook with correct timeout (not zero, not duplicates)
|
|
2117
|
+
if hcom_hook_count != 1 or not hcom_hook_timeout_valid:
|
|
2118
|
+
return False
|
|
2119
|
+
|
|
2120
|
+
# Check that HCOM env var is set
|
|
2121
|
+
env = settings.get('env', {})
|
|
2122
|
+
if 'HCOM' not in env:
|
|
2123
|
+
return False
|
|
2124
|
+
|
|
2125
|
+
return True
|
|
2126
|
+
except Exception:
|
|
2127
|
+
return False
|
|
2128
|
+
|
|
2129
|
+
def is_interactive() -> bool:
|
|
2130
|
+
"""Check if running in interactive mode"""
|
|
2131
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
2132
|
+
|
|
2133
|
+
def get_archive_timestamp() -> str:
|
|
2134
|
+
"""Get timestamp for archive files"""
|
|
2135
|
+
return datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
2136
|
+
|
|
2137
|
+
class LogParseResult(NamedTuple):
|
|
2138
|
+
"""Result from parsing log messages"""
|
|
2139
|
+
messages: list[dict[str, str]]
|
|
2140
|
+
end_position: int
|
|
2141
|
+
|
|
2142
|
+
def parse_log_messages(log_file: Path, start_pos: int = 0) -> LogParseResult:
|
|
2143
|
+
"""Parse messages from log file
|
|
2144
|
+
Args:
|
|
2145
|
+
log_file: Path to log file
|
|
2146
|
+
start_pos: Position to start reading from
|
|
2147
|
+
Returns:
|
|
2148
|
+
LogParseResult containing messages and end position
|
|
2149
|
+
"""
|
|
2150
|
+
if not log_file.exists():
|
|
2151
|
+
return LogParseResult([], start_pos)
|
|
2152
|
+
|
|
2153
|
+
def read_messages(f):
|
|
2154
|
+
f.seek(start_pos)
|
|
2155
|
+
content = f.read()
|
|
2156
|
+
end_pos = f.tell() # Capture actual end position
|
|
2157
|
+
|
|
2158
|
+
if not content.strip():
|
|
2159
|
+
return LogParseResult([], end_pos)
|
|
2160
|
+
|
|
2161
|
+
messages = []
|
|
2162
|
+
message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
|
|
2163
|
+
|
|
2164
|
+
for entry in message_entries:
|
|
2165
|
+
if not entry or '|' not in entry:
|
|
2166
|
+
continue
|
|
2167
|
+
|
|
2168
|
+
parts = entry.split('|', 2)
|
|
2169
|
+
if len(parts) == 3:
|
|
2170
|
+
timestamp, from_instance, message = parts
|
|
2171
|
+
messages.append({
|
|
2172
|
+
'timestamp': timestamp,
|
|
2173
|
+
'from': from_instance.replace('\\|', '|'),
|
|
2174
|
+
'message': message.replace('\\|', '|')
|
|
2175
|
+
})
|
|
2176
|
+
|
|
2177
|
+
return LogParseResult(messages, end_pos)
|
|
2178
|
+
|
|
2179
|
+
return read_file_with_retry(
|
|
2180
|
+
log_file,
|
|
2181
|
+
read_messages,
|
|
2182
|
+
default=LogParseResult([], start_pos)
|
|
2183
|
+
)
|
|
2184
|
+
|
|
2185
|
+
def get_subagent_messages(parent_name: str, since_pos: int = 0, limit: int = 0) -> tuple[list[dict[str, str]], int, dict[str, int]]:
|
|
2186
|
+
"""Get messages from/to subagents of parent instance
|
|
2187
|
+
Args:
|
|
2188
|
+
parent_name: Parent instance name (e.g., 'alice')
|
|
2189
|
+
since_pos: Position to read from (default 0 = all messages)
|
|
2190
|
+
limit: Max messages to return (0 = all)
|
|
2191
|
+
Returns:
|
|
2192
|
+
Tuple of (messages from/to subagents, end_position, per_subagent_counts)
|
|
2193
|
+
per_subagent_counts: {'alice_reviewer': 2, 'alice_debugger': 0, ...}
|
|
2194
|
+
"""
|
|
2195
|
+
log_file = hcom_path(LOG_FILE)
|
|
2196
|
+
if not log_file.exists():
|
|
2197
|
+
return [], since_pos, {}
|
|
2198
|
+
|
|
2199
|
+
result = parse_log_messages(log_file, since_pos)
|
|
2200
|
+
all_messages, new_pos = result.messages, result.end_position
|
|
2201
|
+
|
|
2202
|
+
# Get all subagent names for this parent
|
|
2203
|
+
positions = load_all_positions()
|
|
2204
|
+
subagent_names = [name for name in positions.keys()
|
|
2205
|
+
if name.startswith(f"{parent_name}_") and name != parent_name]
|
|
2206
|
+
|
|
2207
|
+
# Initialize per-subagent counts
|
|
2208
|
+
per_subagent_counts = {name: 0 for name in subagent_names}
|
|
2209
|
+
|
|
2210
|
+
# Filter for messages from/to subagents and track per-subagent counts
|
|
2211
|
+
subagent_messages = []
|
|
2212
|
+
for msg in all_messages:
|
|
2213
|
+
sender = msg['from']
|
|
2214
|
+
# Messages FROM subagents
|
|
2215
|
+
if sender.startswith(f"{parent_name}_") and sender != parent_name:
|
|
2216
|
+
subagent_messages.append(msg)
|
|
2217
|
+
# Track which subagents would receive this message
|
|
2218
|
+
for subagent_name in subagent_names:
|
|
2219
|
+
if subagent_name != sender and should_deliver_message(msg, subagent_name, subagent_names):
|
|
2220
|
+
per_subagent_counts[subagent_name] += 1
|
|
2221
|
+
# Messages TO subagents via @mentions or broadcasts
|
|
2222
|
+
elif subagent_names:
|
|
2223
|
+
# Check which subagents should receive this message
|
|
2224
|
+
matched = False
|
|
2225
|
+
for subagent_name in subagent_names:
|
|
2226
|
+
if should_deliver_message(msg, subagent_name, subagent_names):
|
|
2227
|
+
if not matched:
|
|
2228
|
+
subagent_messages.append(msg)
|
|
2229
|
+
matched = True
|
|
2230
|
+
per_subagent_counts[subagent_name] += 1
|
|
2231
|
+
|
|
2232
|
+
if limit > 0:
|
|
2233
|
+
subagent_messages = subagent_messages[-limit:]
|
|
2234
|
+
|
|
2235
|
+
return subagent_messages, new_pos, per_subagent_counts
|
|
2236
|
+
|
|
2237
|
+
def get_unread_messages(instance_name: str, update_position: bool = False) -> list[dict[str, str]]:
|
|
2238
|
+
"""Get unread messages for instance with @-mention filtering
|
|
2239
|
+
Args:
|
|
2240
|
+
instance_name: Name of instance to get messages for
|
|
2241
|
+
update_position: If True, mark messages as read by updating position
|
|
2242
|
+
"""
|
|
2243
|
+
log_file = hcom_path(LOG_FILE)
|
|
2244
|
+
|
|
2245
|
+
if not log_file.exists():
|
|
2246
|
+
return []
|
|
2247
|
+
|
|
2248
|
+
positions = load_all_positions()
|
|
2249
|
+
|
|
2250
|
+
# Get last position for this instance
|
|
2251
|
+
last_pos = 0
|
|
2252
|
+
if instance_name in positions:
|
|
2253
|
+
pos_data = positions.get(instance_name, {})
|
|
2254
|
+
last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
|
|
2255
|
+
|
|
2256
|
+
# Atomic read with position tracking
|
|
2257
|
+
result = parse_log_messages(log_file, last_pos)
|
|
2258
|
+
all_messages, new_pos = result.messages, result.end_position
|
|
2259
|
+
|
|
2260
|
+
# Filter messages:
|
|
2261
|
+
# 1. Exclude own messages
|
|
2262
|
+
# 2. Apply @-mention filtering
|
|
2263
|
+
all_instance_names = list(positions.keys())
|
|
2264
|
+
messages = []
|
|
2265
|
+
for msg in all_messages:
|
|
2266
|
+
if msg['from'] != instance_name:
|
|
2267
|
+
if should_deliver_message(msg, instance_name, all_instance_names):
|
|
2268
|
+
messages.append(msg)
|
|
2269
|
+
|
|
2270
|
+
# Only update position (ie mark as read) if explicitly requested (after successful delivery)
|
|
2271
|
+
if update_position:
|
|
2272
|
+
update_instance_position(instance_name, {'pos': new_pos})
|
|
2273
|
+
|
|
2274
|
+
return messages
|
|
2275
|
+
|
|
2276
|
+
def get_instance_status(pos_data: dict[str, Any]) -> tuple[bool, str, str, str]:
|
|
2277
|
+
"""Get current status of instance. Returns (enabled, status, age_string, description).
|
|
2278
|
+
|
|
2279
|
+
age_string format: "16m" (clean format, no parens/suffix - consumers handle display)
|
|
2280
|
+
|
|
2281
|
+
Status is activity state (what instance is doing).
|
|
2282
|
+
Enabled is participation flag (whether instance can send/receive HCOM).
|
|
2283
|
+
These are orthogonal - can be disabled but still active.
|
|
2284
|
+
"""
|
|
2285
|
+
enabled = pos_data.get('enabled', False)
|
|
2286
|
+
status = pos_data.get('status', 'unknown')
|
|
2287
|
+
status_time = pos_data.get('status_time', 0)
|
|
2288
|
+
status_context = pos_data.get('status_context', '')
|
|
2289
|
+
|
|
2290
|
+
now = int(time.time())
|
|
2291
|
+
age = now - status_time if status_time else 0
|
|
2292
|
+
|
|
2293
|
+
# Subagent-specific status detection
|
|
2294
|
+
if pos_data.get('parent_session_id'):
|
|
2295
|
+
# Subagent in done polling loop: status='active' but heartbeat still updating
|
|
2296
|
+
# PreToolUse sets all subagents to 'active', but one in polling loop has fresh heartbeat
|
|
2297
|
+
if status == 'active' and enabled:
|
|
2298
|
+
heartbeat_age = now - pos_data.get('last_stop', 0)
|
|
2299
|
+
if heartbeat_age < 1.5: # Heartbeat active (1s poll interval + margin)
|
|
2300
|
+
status = 'waiting'
|
|
2301
|
+
age = heartbeat_age
|
|
2302
|
+
|
|
2303
|
+
# Heartbeat timeout check: instance was waiting but heartbeat died
|
|
2304
|
+
# This detects terminated instances (closed window/crashed) that were idle
|
|
2305
|
+
if status == 'waiting':
|
|
2306
|
+
heartbeat_age = now - pos_data.get('last_stop', 0)
|
|
2307
|
+
tcp_mode = pos_data.get('tcp_mode', False)
|
|
2308
|
+
threshold = 40 if tcp_mode else 2
|
|
2309
|
+
if heartbeat_age > threshold:
|
|
2310
|
+
status_context = status # Save what it was doing
|
|
2311
|
+
status = 'stale'
|
|
2312
|
+
age = heartbeat_age
|
|
2313
|
+
|
|
2314
|
+
# Activity timeout check: no status updates for extended period
|
|
2315
|
+
# This detects terminated instances that were active/blocked/etc when closed
|
|
2316
|
+
if status not in ['exited', 'stale']:
|
|
2317
|
+
timeout = pos_data.get('wait_timeout', 1800)
|
|
2318
|
+
min_threshold = max(timeout + 60, 600) # Timeout + 1min buffer, minimum 10min
|
|
2319
|
+
status_age = now - status_time if status_time else 0
|
|
2320
|
+
if status_age > min_threshold:
|
|
2321
|
+
status_context = status # Save what it was doing
|
|
2322
|
+
status = 'stale'
|
|
2323
|
+
age = status_age
|
|
2324
|
+
|
|
2325
|
+
# Build description from status and context
|
|
2326
|
+
description = get_status_description(status, status_context)
|
|
2327
|
+
|
|
2328
|
+
return (enabled, status, format_age(age), description)
|
|
2329
|
+
|
|
2330
|
+
|
|
2331
|
+
def get_status_description(status: str, context: str = '') -> str:
|
|
2332
|
+
"""Build human-readable status description"""
|
|
2333
|
+
if status == 'active':
|
|
2334
|
+
return f"{context} executing" if context else "active"
|
|
2335
|
+
elif status == 'delivered':
|
|
2336
|
+
return f"msg from {context}" if context else "message delivered"
|
|
2337
|
+
elif status == 'waiting':
|
|
2338
|
+
return "idle"
|
|
2339
|
+
elif status == 'blocked':
|
|
2340
|
+
return f"{context}" if context else "permission needed"
|
|
2341
|
+
elif status == 'exited':
|
|
2342
|
+
return f"exited: {context}" if context else "exited"
|
|
2343
|
+
elif status == 'stale':
|
|
2344
|
+
# Show what it was doing when it went stale
|
|
2345
|
+
if context == 'waiting':
|
|
2346
|
+
return "idle [stale]"
|
|
2347
|
+
elif context == 'active':
|
|
2348
|
+
return "active [stale]"
|
|
2349
|
+
elif context == 'blocked':
|
|
2350
|
+
return "blocked [stale]"
|
|
2351
|
+
elif context == 'delivered':
|
|
2352
|
+
return "delivered [stale]"
|
|
2353
|
+
else:
|
|
2354
|
+
return "stale"
|
|
2355
|
+
else:
|
|
2356
|
+
return "unknown"
|
|
2357
|
+
|
|
2358
|
+
def should_show_in_watch(d: dict[str, Any]) -> bool:
|
|
2359
|
+
"""Show previously-enabled instances, hide vanilla never-enabled instances"""
|
|
2360
|
+
# Hide instances that never participated
|
|
2361
|
+
if not d.get('previously_enabled', False):
|
|
2362
|
+
return False
|
|
2363
|
+
|
|
2364
|
+
return True
|
|
2365
|
+
|
|
2366
|
+
def initialize_instance_in_position_file(instance_name: str, session_id: str | None = None, parent_session_id: str | None = None, enabled: bool | None = None) -> bool:
|
|
2367
|
+
"""Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
|
|
2368
|
+
try:
|
|
2369
|
+
data = load_instance_position(instance_name)
|
|
2370
|
+
file_existed = bool(data)
|
|
2371
|
+
|
|
2372
|
+
# Determine default enabled state: True for hcom-launched, False for vanilla
|
|
2373
|
+
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
2374
|
+
|
|
2375
|
+
# Determine starting position: skip history or read from beginning (or last max_msgs num)
|
|
2376
|
+
initial_pos = 0
|
|
2377
|
+
if SKIP_HISTORY:
|
|
2378
|
+
log_file = hcom_path(LOG_FILE)
|
|
2379
|
+
if log_file.exists():
|
|
2380
|
+
initial_pos = log_file.stat().st_size
|
|
2381
|
+
|
|
2382
|
+
# Determine enabled state: explicit param > hcom-launched > False
|
|
2383
|
+
if enabled is not None:
|
|
2384
|
+
default_enabled = enabled
|
|
2385
|
+
else:
|
|
2386
|
+
default_enabled = is_hcom_launched
|
|
2387
|
+
|
|
2388
|
+
defaults = {
|
|
2389
|
+
"pos": initial_pos,
|
|
2390
|
+
"enabled": default_enabled,
|
|
2391
|
+
"previously_enabled": default_enabled,
|
|
2392
|
+
"directory": str(Path.cwd()),
|
|
2393
|
+
"last_stop": 0,
|
|
2394
|
+
"created_at": time.time(),
|
|
2395
|
+
"session_id": session_id or "",
|
|
2396
|
+
"transcript_path": "",
|
|
2397
|
+
"notification_message": "",
|
|
2398
|
+
"alias_announced": False,
|
|
2399
|
+
"tag": None
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
# Add parent_session_id for subagents
|
|
2403
|
+
if parent_session_id:
|
|
2404
|
+
defaults["parent_session_id"] = parent_session_id
|
|
2405
|
+
|
|
2406
|
+
# Add missing fields (preserve existing)
|
|
2407
|
+
for key, value in defaults.items():
|
|
2408
|
+
data.setdefault(key, value)
|
|
2409
|
+
|
|
2410
|
+
return save_instance_position(instance_name, data)
|
|
2411
|
+
except Exception:
|
|
2412
|
+
return False
|
|
2413
|
+
|
|
2414
|
+
def update_instance_position(instance_name: str, update_fields: dict[str, Any]) -> None:
|
|
2415
|
+
"""Update instance position atomically with file locking"""
|
|
2416
|
+
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json")
|
|
2417
|
+
|
|
2418
|
+
try:
|
|
2419
|
+
if instance_file.exists():
|
|
2420
|
+
with open(instance_file, 'r+', encoding='utf-8') as f:
|
|
2421
|
+
with locked(f):
|
|
2422
|
+
data = json.load(f)
|
|
2423
|
+
data.update(update_fields)
|
|
2424
|
+
f.seek(0)
|
|
2425
|
+
f.truncate()
|
|
2426
|
+
json.dump(data, f, indent=2)
|
|
2427
|
+
f.flush()
|
|
2428
|
+
os.fsync(f.fileno())
|
|
2429
|
+
else:
|
|
2430
|
+
# File doesn't exist - create it first
|
|
2431
|
+
initialize_instance_in_position_file(instance_name)
|
|
2432
|
+
except Exception as e:
|
|
2433
|
+
log_hook_error(f'update_instance_position:{instance_name}', e)
|
|
2434
|
+
pass # Silent to user, logged for debugging
|
|
2435
|
+
|
|
2436
|
+
def enable_instance(instance_name: str) -> None:
|
|
2437
|
+
"""Enable instance - enables Stop hook polling"""
|
|
2438
|
+
update_instance_position(instance_name, {
|
|
2439
|
+
'enabled': True,
|
|
2440
|
+
'previously_enabled': True
|
|
2441
|
+
})
|
|
2442
|
+
|
|
2443
|
+
def disable_instance(instance_name: str) -> None:
|
|
2444
|
+
"""Disable instance - stops Stop hook polling"""
|
|
2445
|
+
updates = {
|
|
2446
|
+
'enabled': False
|
|
2447
|
+
}
|
|
2448
|
+
update_instance_position(instance_name, updates)
|
|
2449
|
+
|
|
2450
|
+
# Notify instance to wake and see enabled=false
|
|
2451
|
+
notify_instance(instance_name)
|
|
2452
|
+
|
|
2453
|
+
def set_status(instance_name: str, status: str, context: str = ''):
|
|
2454
|
+
"""Set instance status with timestamp"""
|
|
2455
|
+
update_instance_position(instance_name, {
|
|
2456
|
+
'status': status,
|
|
2457
|
+
'status_time': int(time.time()),
|
|
2458
|
+
'status_context': context
|
|
2459
|
+
})
|
|
2460
|
+
|
|
2461
|
+
# ==================== Command Functions ====================
|
|
2462
|
+
|
|
2463
|
+
def cmd_help() -> int:
|
|
2464
|
+
"""Show help text"""
|
|
2465
|
+
print(get_help_text())
|
|
2466
|
+
return 0
|
|
2467
|
+
|
|
2468
|
+
def cmd_launch(argv: list[str]) -> int:
|
|
2469
|
+
"""Launch Claude instances: hcom [N] [claude] [args]"""
|
|
2470
|
+
try:
|
|
2471
|
+
# Parse arguments: hcom [N] [claude] [args]
|
|
2472
|
+
count = 1
|
|
2473
|
+
forwarded = []
|
|
2474
|
+
|
|
2475
|
+
# Extract count if first arg is digit
|
|
2476
|
+
if argv and argv[0].isdigit():
|
|
2477
|
+
count = int(argv[0])
|
|
2478
|
+
if count <= 0:
|
|
2479
|
+
raise CLIError('Count must be positive.')
|
|
2480
|
+
if count > 100:
|
|
2481
|
+
raise CLIError('Too many instances requested (max 100).')
|
|
2482
|
+
argv = argv[1:]
|
|
2483
|
+
|
|
2484
|
+
# Skip 'claude' keyword if present
|
|
2485
|
+
if argv and argv[0] == 'claude':
|
|
2486
|
+
argv = argv[1:]
|
|
2487
|
+
|
|
2488
|
+
# Forward all remaining args to claude CLI
|
|
2489
|
+
forwarded = argv
|
|
2490
|
+
|
|
2491
|
+
# Check for --no-auto-watch flag (used by TUI to prevent opening another watch window)
|
|
2492
|
+
no_auto_watch = '--no-auto-watch' in forwarded
|
|
2493
|
+
if no_auto_watch:
|
|
2494
|
+
forwarded = [arg for arg in forwarded if arg != '--no-auto-watch']
|
|
2495
|
+
|
|
2496
|
+
# Get tag from config
|
|
2497
|
+
tag = get_config().tag
|
|
2498
|
+
if tag and '|' in tag:
|
|
2499
|
+
raise CLIError('Tag cannot contain "|" characters.')
|
|
2500
|
+
|
|
2501
|
+
# Get agents from config (comma-separated)
|
|
2502
|
+
agent_env = get_config().agent
|
|
2503
|
+
agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['']
|
|
2504
|
+
|
|
2505
|
+
# Phase 1: Parse Claude args using new resolve_claude_args
|
|
2506
|
+
spec = resolve_claude_args(
|
|
2507
|
+
forwarded if forwarded else None,
|
|
2508
|
+
get_config().claude_args
|
|
2509
|
+
)
|
|
2510
|
+
|
|
2511
|
+
# Validate parsed args
|
|
2512
|
+
if spec.has_errors():
|
|
2513
|
+
raise CLIError('\n'.join(spec.errors))
|
|
2514
|
+
|
|
2515
|
+
# Check for conflicts (warnings only, not errors)
|
|
2516
|
+
warnings = validate_conflicts(spec)
|
|
2517
|
+
for warning in warnings:
|
|
2518
|
+
print(f"{FG_YELLOW}Warning:{RESET} {warning}", file=sys.stderr)
|
|
2519
|
+
|
|
2520
|
+
# Add HCOM background mode enhancements
|
|
2521
|
+
spec = add_background_defaults(spec)
|
|
2522
|
+
|
|
2523
|
+
# Extract values from spec
|
|
2524
|
+
background = spec.is_background
|
|
2525
|
+
# Use full tokens (prompts included) - respects user's HCOM_CLAUDE_ARGS config
|
|
2526
|
+
claude_args = spec.rebuild_tokens(include_system=True)
|
|
2527
|
+
|
|
2528
|
+
terminal_mode = get_config().terminal
|
|
2529
|
+
|
|
2530
|
+
# Calculate total instances to launch
|
|
2531
|
+
total_instances = count * len(agents)
|
|
2532
|
+
|
|
2533
|
+
# Fail fast for here mode with multiple instances
|
|
2534
|
+
if terminal_mode == 'here' and total_instances > 1:
|
|
2535
|
+
print(format_error(
|
|
2536
|
+
f"'here' mode cannot launch {total_instances} instances (it's one terminal window)",
|
|
2537
|
+
"Use 'hcom 1' for one generic instance"
|
|
2538
|
+
), file=sys.stderr)
|
|
2539
|
+
return 1
|
|
2540
|
+
|
|
2541
|
+
log_file = hcom_path(LOG_FILE)
|
|
2542
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2543
|
+
|
|
2544
|
+
if not log_file.exists():
|
|
2545
|
+
log_file.touch()
|
|
2546
|
+
|
|
2547
|
+
# Build environment variables for Claude instances
|
|
2548
|
+
base_env = build_claude_env()
|
|
2549
|
+
|
|
2550
|
+
# Add tag-specific hints if provided
|
|
2551
|
+
if tag:
|
|
2552
|
+
base_env['HCOM_TAG'] = tag
|
|
2553
|
+
|
|
2554
|
+
launched = 0
|
|
2555
|
+
|
|
2556
|
+
# Launch count instances of each agent
|
|
2557
|
+
for agent in agents:
|
|
2558
|
+
for _ in range(count):
|
|
2559
|
+
instance_type = agent
|
|
2560
|
+
instance_env = base_env.copy()
|
|
2561
|
+
|
|
2562
|
+
# Mark all hcom-launched instances
|
|
2563
|
+
instance_env['HCOM_LAUNCHED'] = '1'
|
|
2564
|
+
|
|
2565
|
+
# Mark background instances via environment with log filename
|
|
2566
|
+
if background:
|
|
2567
|
+
# Generate unique log filename
|
|
2568
|
+
log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
|
|
2569
|
+
instance_env['HCOM_BACKGROUND'] = log_filename
|
|
2570
|
+
|
|
2571
|
+
# Build claude command
|
|
2572
|
+
if not instance_type:
|
|
2573
|
+
# No agent - no agent content
|
|
2574
|
+
claude_cmd, _ = build_claude_command(
|
|
2575
|
+
agent_content=None,
|
|
2576
|
+
claude_args=claude_args
|
|
2577
|
+
)
|
|
2578
|
+
else:
|
|
2579
|
+
# Agent instance
|
|
2580
|
+
try:
|
|
2581
|
+
agent_content, agent_config = resolve_agent(instance_type)
|
|
2582
|
+
# Mark this as a subagent instance for SessionStart hook
|
|
2583
|
+
instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
|
|
2584
|
+
# Prepend agent instance awareness to system prompt
|
|
2585
|
+
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
2586
|
+
agent_content = agent_prefix + agent_content
|
|
2587
|
+
# Use agent's model and tools if specified and not overridden in claude_args
|
|
2588
|
+
agent_model = agent_config.get('model')
|
|
2589
|
+
agent_tools = agent_config.get('tools')
|
|
2590
|
+
claude_cmd, _ = build_claude_command(
|
|
2591
|
+
agent_content=agent_content,
|
|
2592
|
+
claude_args=claude_args,
|
|
2593
|
+
model=agent_model,
|
|
2594
|
+
tools=agent_tools
|
|
2595
|
+
)
|
|
2596
|
+
# Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
|
|
2597
|
+
except (FileNotFoundError, ValueError) as e:
|
|
2598
|
+
print(str(e), file=sys.stderr)
|
|
2599
|
+
continue
|
|
2600
|
+
|
|
2601
|
+
try:
|
|
2602
|
+
if background:
|
|
2603
|
+
log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
|
|
2604
|
+
if log_file:
|
|
2605
|
+
print(f"Headless instance launched, log: {log_file}")
|
|
2606
|
+
launched += 1
|
|
2607
|
+
else:
|
|
2608
|
+
if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
|
|
2609
|
+
launched += 1
|
|
2610
|
+
except Exception as e:
|
|
2611
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
2612
|
+
|
|
2613
|
+
requested = total_instances
|
|
2614
|
+
failed = requested - launched
|
|
2615
|
+
|
|
2616
|
+
if launched == 0:
|
|
2617
|
+
print(format_error(f"No instances launched (0/{requested})"), file=sys.stderr)
|
|
2618
|
+
return 1
|
|
2619
|
+
|
|
2620
|
+
# Show results
|
|
2621
|
+
if failed > 0:
|
|
2622
|
+
print(f"Launched {launched}/{requested} Claude instance{'s' if requested != 1 else ''} ({failed} failed)")
|
|
2623
|
+
else:
|
|
2624
|
+
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
2625
|
+
|
|
2626
|
+
# Auto-launch watch dashboard if in new window mode (new or custom) and all instances launched successfully
|
|
2627
|
+
terminal_mode = get_config().terminal
|
|
2628
|
+
|
|
2629
|
+
# Only auto-watch if ALL instances launched successfully and launches windows (not 'here' or 'print') and not disabled by TUI
|
|
2630
|
+
if terminal_mode not in ('here', 'print') and failed == 0 and is_interactive() and not no_auto_watch:
|
|
2631
|
+
# Show tips first if needed
|
|
2632
|
+
if tag:
|
|
2633
|
+
print(f"\n • Send to {tag} team: hcom send '@{tag} message'")
|
|
2634
|
+
|
|
2635
|
+
# Clear transition message
|
|
2636
|
+
print("\nOpening hcom UI...")
|
|
2637
|
+
time.sleep(2) # Brief pause so user sees the message
|
|
2638
|
+
|
|
2639
|
+
# Launch interactive watch dashboard in current terminal
|
|
2640
|
+
return cmd_watch([]) # Empty argv = interactive mode
|
|
2641
|
+
else:
|
|
2642
|
+
tips = [
|
|
2643
|
+
"Run 'hcom' to view/send in conversation dashboard",
|
|
2644
|
+
]
|
|
2645
|
+
if tag:
|
|
2646
|
+
tips.append(f"Send to {tag} team: hcom send '@{tag} message'")
|
|
2647
|
+
|
|
2648
|
+
if tips:
|
|
2649
|
+
print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
|
|
2650
|
+
|
|
2651
|
+
return 0
|
|
2652
|
+
|
|
2653
|
+
except ValueError as e:
|
|
2654
|
+
print(str(e), file=sys.stderr)
|
|
2655
|
+
return 1
|
|
2656
|
+
except Exception as e:
|
|
2657
|
+
print(str(e), file=sys.stderr)
|
|
2658
|
+
return 1
|
|
2659
|
+
|
|
2660
|
+
def cmd_watch(argv: list[str]) -> int:
|
|
2661
|
+
"""View conversation dashboard: hcom watch [--logs|--status|--wait [SEC]]"""
|
|
2662
|
+
# Extract launch flag for external terminals (used by claude code bootstrap)
|
|
2663
|
+
cleaned_args: list[str] = []
|
|
2664
|
+
for arg in argv:
|
|
2665
|
+
if arg == '--launch':
|
|
2666
|
+
watch_cmd = f"{build_hcom_command()} watch"
|
|
2667
|
+
result = launch_terminal(watch_cmd, build_claude_env(), cwd=os.getcwd())
|
|
2668
|
+
return 0 if result else 1
|
|
2669
|
+
else:
|
|
2670
|
+
cleaned_args.append(arg)
|
|
2671
|
+
argv = cleaned_args
|
|
2672
|
+
|
|
2673
|
+
# Parse arguments
|
|
2674
|
+
show_logs = '--logs' in argv
|
|
2675
|
+
show_status = '--status' in argv
|
|
2676
|
+
wait_timeout = None
|
|
2677
|
+
|
|
2678
|
+
# Check for --wait flag
|
|
2679
|
+
if '--wait' in argv:
|
|
2680
|
+
idx = argv.index('--wait')
|
|
2681
|
+
if idx + 1 < len(argv):
|
|
2682
|
+
try:
|
|
2683
|
+
wait_timeout = int(argv[idx + 1])
|
|
2684
|
+
if wait_timeout < 0:
|
|
2685
|
+
raise CLIError('--wait expects a non-negative number of seconds.')
|
|
2686
|
+
except ValueError:
|
|
2687
|
+
wait_timeout = 60 # Default for non-numeric values
|
|
2688
|
+
else:
|
|
2689
|
+
wait_timeout = 60 # Default timeout
|
|
2690
|
+
show_logs = True # --wait implies logs mode
|
|
2691
|
+
|
|
2692
|
+
log_file = hcom_path(LOG_FILE)
|
|
2693
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2694
|
+
|
|
2695
|
+
if not log_file.exists() and not instances_dir.exists():
|
|
2696
|
+
print(format_error("No conversation log found", "Run 'hcom' first"), file=sys.stderr)
|
|
2697
|
+
return 1
|
|
2698
|
+
|
|
2699
|
+
# Non-interactive mode (no TTY or flags specified)
|
|
2700
|
+
if not is_interactive() or show_logs or show_status:
|
|
2701
|
+
if show_logs:
|
|
2702
|
+
# Atomic position capture BEFORE parsing (prevents race condition)
|
|
2703
|
+
if log_file.exists():
|
|
2704
|
+
last_pos = log_file.stat().st_size # Capture position first
|
|
2705
|
+
messages = parse_log_messages(log_file).messages
|
|
2706
|
+
else:
|
|
2707
|
+
last_pos = 0
|
|
2708
|
+
messages = []
|
|
2709
|
+
|
|
2710
|
+
# If --wait, show recent messages (max of: last 3 messages OR all messages in last 5 seconds)
|
|
2711
|
+
if wait_timeout is not None:
|
|
2712
|
+
cutoff = datetime.now() - timedelta(seconds=5)
|
|
2713
|
+
recent_by_time = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
|
|
2714
|
+
last_three = messages[-3:] if len(messages) >= 3 else messages
|
|
2715
|
+
# Show whichever is larger: recent by time or last 3
|
|
2716
|
+
recent_messages = recent_by_time if len(recent_by_time) > len(last_three) else last_three
|
|
2717
|
+
# Status to stderr, data to stdout
|
|
2718
|
+
if recent_messages:
|
|
2719
|
+
print(f'---Showing recent messages---', file=sys.stderr)
|
|
2720
|
+
for msg in recent_messages:
|
|
2721
|
+
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2722
|
+
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
2723
|
+
else:
|
|
2724
|
+
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
2725
|
+
|
|
2726
|
+
|
|
2727
|
+
# Wait loop
|
|
2728
|
+
start_time = time.time()
|
|
2729
|
+
while time.time() - start_time < wait_timeout:
|
|
2730
|
+
if log_file.exists():
|
|
2731
|
+
current_size = log_file.stat().st_size
|
|
2732
|
+
new_messages = []
|
|
2733
|
+
if current_size > last_pos:
|
|
2734
|
+
# Capture new position BEFORE parsing (atomic)
|
|
2735
|
+
new_messages = parse_log_messages(log_file, last_pos).messages
|
|
2736
|
+
if new_messages:
|
|
2737
|
+
for msg in new_messages:
|
|
2738
|
+
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2739
|
+
last_pos = current_size # Update only after successful processing
|
|
2740
|
+
return 0 # Success - got new messages
|
|
2741
|
+
if current_size > last_pos:
|
|
2742
|
+
last_pos = current_size # Update even if no messages (file grew but no complete messages yet)
|
|
2743
|
+
time.sleep(0.1)
|
|
2744
|
+
|
|
2745
|
+
# Timeout message to stderr
|
|
2746
|
+
print(f'[TIMED OUT] No new messages received after {wait_timeout} seconds.', file=sys.stderr)
|
|
2747
|
+
return 1 # Timeout - no new messages
|
|
2748
|
+
|
|
2749
|
+
# Regular --logs (no --wait): print all messages to stdout
|
|
2750
|
+
else:
|
|
2751
|
+
if messages:
|
|
2752
|
+
for msg in messages:
|
|
2753
|
+
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2754
|
+
else:
|
|
2755
|
+
print("No messages yet", file=sys.stderr)
|
|
2756
|
+
|
|
2757
|
+
|
|
2758
|
+
elif show_status:
|
|
2759
|
+
# Build JSON output
|
|
2760
|
+
positions = load_all_positions()
|
|
2761
|
+
|
|
2762
|
+
instances = {}
|
|
2763
|
+
status_counts = {}
|
|
2764
|
+
|
|
2765
|
+
for name, data in positions.items():
|
|
2766
|
+
if not should_show_in_watch(data):
|
|
2767
|
+
continue
|
|
2768
|
+
enabled, status, age, description = get_instance_status(data)
|
|
2769
|
+
instances[name] = {
|
|
2770
|
+
"enabled": enabled,
|
|
2771
|
+
"status": status,
|
|
2772
|
+
"age": age if age else "",
|
|
2773
|
+
"description": description,
|
|
2774
|
+
"directory": data.get("directory", "unknown"),
|
|
2775
|
+
"session_id": data.get("session_id", ""),
|
|
2776
|
+
"background": bool(data.get("background"))
|
|
2777
|
+
}
|
|
2778
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
|
2779
|
+
|
|
2780
|
+
# Get recent messages
|
|
2781
|
+
messages = []
|
|
2782
|
+
if log_file.exists():
|
|
2783
|
+
all_messages = parse_log_messages(log_file).messages
|
|
2784
|
+
messages = all_messages[-5:] if all_messages else []
|
|
2785
|
+
|
|
2786
|
+
# Output JSON
|
|
2787
|
+
output = {
|
|
2788
|
+
"instances": instances,
|
|
2789
|
+
"recent_messages": messages,
|
|
2790
|
+
"status_summary": status_counts,
|
|
2791
|
+
"log_file": str(log_file),
|
|
2792
|
+
"timestamp": datetime.now().isoformat()
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
print(json.dumps(output, indent=2))
|
|
2796
|
+
else:
|
|
2797
|
+
print("No TTY - Automation usage:", file=sys.stderr)
|
|
2798
|
+
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
2799
|
+
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
2800
|
+
print(" hcom watch --wait Wait for new messages", file=sys.stderr)
|
|
2801
|
+
print(" hcom watch --launch Launch interactive dashboard in new terminal", file=sys.stderr)
|
|
2802
|
+
print(" Full information: hcom --help")
|
|
2803
|
+
|
|
2804
|
+
return 0
|
|
2805
|
+
|
|
2806
|
+
# Interactive mode - launch TUI
|
|
2807
|
+
try:
|
|
2808
|
+
from .ui import HcomTUI
|
|
2809
|
+
tui = HcomTUI(hcom_path())
|
|
2810
|
+
return tui.run()
|
|
2811
|
+
except ImportError as e:
|
|
2812
|
+
print(format_error("TUI not available", "Install with pip install hcom"), file=sys.stderr)
|
|
2813
|
+
return 1
|
|
2814
|
+
|
|
2815
|
+
def clear() -> int:
|
|
2816
|
+
"""Clear and archive conversation"""
|
|
2817
|
+
log_file = hcom_path(LOG_FILE)
|
|
2818
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2819
|
+
|
|
2820
|
+
# cleanup: temp files, old scripts, old outbox files
|
|
2821
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2822
|
+
if instances_dir.exists():
|
|
2823
|
+
sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
|
|
2824
|
+
|
|
2825
|
+
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
2826
|
+
if scripts_dir.exists():
|
|
2827
|
+
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)
|
|
2828
|
+
|
|
2829
|
+
# Check if hcom files exist
|
|
2830
|
+
if not log_file.exists() and not instances_dir.exists():
|
|
2831
|
+
print("No HCOM conversation to clear")
|
|
2832
|
+
return 0
|
|
2833
|
+
|
|
2834
|
+
# Archive existing files if they have content
|
|
2835
|
+
timestamp = get_archive_timestamp()
|
|
2836
|
+
archived = False
|
|
2837
|
+
|
|
2838
|
+
try:
|
|
2839
|
+
has_log = log_file.exists() and log_file.stat().st_size > 0
|
|
2840
|
+
has_instances = instances_dir.exists() and any(instances_dir.glob('*.json'))
|
|
2841
|
+
|
|
2842
|
+
if has_log or has_instances:
|
|
2843
|
+
# Create session archive folder with timestamp
|
|
2844
|
+
session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
|
|
2845
|
+
session_archive.mkdir(parents=True, exist_ok=True)
|
|
2846
|
+
|
|
2847
|
+
# Archive log file
|
|
2848
|
+
if has_log:
|
|
2849
|
+
archive_log = session_archive / LOG_FILE
|
|
2850
|
+
log_file.rename(archive_log)
|
|
2851
|
+
archived = True
|
|
2852
|
+
elif log_file.exists():
|
|
2853
|
+
log_file.unlink()
|
|
2854
|
+
|
|
2855
|
+
# Archive instances
|
|
2856
|
+
if has_instances:
|
|
2857
|
+
archive_instances = session_archive / INSTANCES_DIR
|
|
2858
|
+
archive_instances.mkdir(parents=True, exist_ok=True)
|
|
2859
|
+
|
|
2860
|
+
# Move json files only
|
|
2861
|
+
for f in instances_dir.glob('*.json'):
|
|
2862
|
+
f.rename(archive_instances / f.name)
|
|
2863
|
+
|
|
2864
|
+
archived = True
|
|
2865
|
+
else:
|
|
2866
|
+
# Clean up empty files/dirs
|
|
2867
|
+
if log_file.exists():
|
|
2868
|
+
log_file.unlink()
|
|
2869
|
+
if instances_dir.exists():
|
|
2870
|
+
shutil.rmtree(instances_dir)
|
|
2871
|
+
|
|
2872
|
+
log_file.touch()
|
|
2873
|
+
clear_all_positions()
|
|
2874
|
+
|
|
2875
|
+
if archived:
|
|
2876
|
+
print(f"Archived to archive/session-{timestamp}/")
|
|
2877
|
+
print("Started fresh HCOM conversation log")
|
|
2878
|
+
return 0
|
|
2879
|
+
|
|
2880
|
+
except Exception as e:
|
|
2881
|
+
print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
|
|
2882
|
+
return 1
|
|
2883
|
+
|
|
2884
|
+
def remove_global_hooks() -> bool:
|
|
2885
|
+
"""Remove HCOM hooks from ~/.claude/settings.json
|
|
2886
|
+
Returns True on success, False on failure."""
|
|
2887
|
+
settings_path = get_claude_settings_path()
|
|
2888
|
+
|
|
2889
|
+
if not settings_path.exists():
|
|
2890
|
+
return True # No settings = no hooks to remove
|
|
2891
|
+
|
|
2892
|
+
try:
|
|
2893
|
+
settings = load_settings_json(settings_path, default=None)
|
|
2894
|
+
if not settings:
|
|
2895
|
+
return False
|
|
2896
|
+
|
|
2897
|
+
_remove_hcom_hooks_from_settings(settings)
|
|
2898
|
+
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
2899
|
+
return True
|
|
2900
|
+
except Exception:
|
|
2901
|
+
return False
|
|
2902
|
+
|
|
2903
|
+
def cleanup_directory_hooks(directory: Path | str) -> tuple[int, str]:
|
|
2904
|
+
"""Remove hcom hooks from a specific directory
|
|
2905
|
+
Returns tuple: (exit_code, message)
|
|
2906
|
+
exit_code: 0 for success, 1 for error
|
|
2907
|
+
message: what happened
|
|
2908
|
+
"""
|
|
2909
|
+
settings_path = Path(directory) / '.claude' / 'settings.local.json'
|
|
2910
|
+
|
|
2911
|
+
if not settings_path.exists():
|
|
2912
|
+
return 0, "No Claude settings found"
|
|
2913
|
+
|
|
2914
|
+
try:
|
|
2915
|
+
# Load existing settings
|
|
2916
|
+
settings = load_settings_json(settings_path, default=None)
|
|
2917
|
+
if not settings:
|
|
2918
|
+
return 1, "Cannot read Claude settings"
|
|
2919
|
+
|
|
2920
|
+
hooks_found = False
|
|
2921
|
+
|
|
2922
|
+
# Include PostToolUse for backward compatibility cleanup
|
|
2923
|
+
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2924
|
+
for event in LEGACY_HOOK_TYPES)
|
|
2925
|
+
|
|
2926
|
+
_remove_hcom_hooks_from_settings(settings)
|
|
2927
|
+
|
|
2928
|
+
# Check if any were removed
|
|
2929
|
+
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2930
|
+
for event in LEGACY_HOOK_TYPES)
|
|
2931
|
+
if new_hook_count < original_hook_count:
|
|
2932
|
+
hooks_found = True
|
|
2933
|
+
|
|
2934
|
+
if not hooks_found:
|
|
2935
|
+
return 0, "No hcom hooks found"
|
|
2936
|
+
|
|
2937
|
+
# Write back or delete settings
|
|
2938
|
+
if not settings or (len(settings) == 0):
|
|
2939
|
+
# Delete empty settings file
|
|
2940
|
+
settings_path.unlink()
|
|
2941
|
+
return 0, "Removed hcom hooks (settings file deleted)"
|
|
2942
|
+
else:
|
|
2943
|
+
# Write updated settings
|
|
2944
|
+
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
2945
|
+
return 0, "Removed hcom hooks from settings"
|
|
2946
|
+
|
|
2947
|
+
except json.JSONDecodeError:
|
|
2948
|
+
return 1, format_error("Corrupted settings.local.json file")
|
|
2949
|
+
except Exception as e:
|
|
2950
|
+
return 1, format_error(f"Cannot modify settings.local.json: {e}")
|
|
2951
|
+
|
|
2952
|
+
|
|
2953
|
+
def cmd_stop(argv: list[str]) -> int:
|
|
2954
|
+
"""Stop instances: hcom stop [alias|all] [--_hcom_session ID]"""
|
|
2955
|
+
# Parse arguments
|
|
2956
|
+
target = None
|
|
2957
|
+
session_id = None
|
|
2958
|
+
|
|
2959
|
+
# Extract --_hcom_session if present
|
|
2960
|
+
if '--_hcom_session' in argv:
|
|
2961
|
+
idx = argv.index('--_hcom_session')
|
|
2962
|
+
if idx + 1 < len(argv):
|
|
2963
|
+
session_id = argv[idx + 1]
|
|
2964
|
+
argv = argv[:idx] + argv[idx + 2:]
|
|
2965
|
+
|
|
2966
|
+
# Remove flags to get target
|
|
2967
|
+
args_without_flags = [a for a in argv if not a.startswith('--')]
|
|
2968
|
+
if args_without_flags:
|
|
2969
|
+
target = args_without_flags[0]
|
|
2970
|
+
|
|
2971
|
+
# Handle 'all' target
|
|
2972
|
+
if target == 'all':
|
|
2973
|
+
positions = load_all_positions()
|
|
2974
|
+
|
|
2975
|
+
if not positions:
|
|
2976
|
+
print("No instances found")
|
|
2977
|
+
return 0
|
|
2978
|
+
|
|
2979
|
+
stopped_count = 0
|
|
2980
|
+
bg_logs = []
|
|
2981
|
+
stopped_names = []
|
|
2982
|
+
for instance_name, instance_data in positions.items():
|
|
2983
|
+
if instance_data.get('enabled', False):
|
|
2984
|
+
# Set external stop flag (stop all is always external)
|
|
2985
|
+
update_instance_position(instance_name, {'external_stop_pending': True})
|
|
2986
|
+
disable_instance(instance_name)
|
|
2987
|
+
stopped_names.append(instance_name)
|
|
2988
|
+
stopped_count += 1
|
|
2989
|
+
|
|
2990
|
+
# Track background logs
|
|
2991
|
+
if instance_data.get('background'):
|
|
2992
|
+
log_file = instance_data.get('background_log_file', '')
|
|
2993
|
+
if log_file:
|
|
2994
|
+
bg_logs.append((instance_name, log_file))
|
|
2995
|
+
|
|
2996
|
+
if stopped_count == 0:
|
|
2997
|
+
print("No instances to stop")
|
|
2998
|
+
else:
|
|
2999
|
+
print(f"Stopped {stopped_count} instance(s): {', '.join(stopped_names)}")
|
|
3000
|
+
|
|
3001
|
+
# Show background logs if any
|
|
3002
|
+
if bg_logs:
|
|
3003
|
+
print()
|
|
3004
|
+
print("Headless instance logs:")
|
|
3005
|
+
for name, log_file in bg_logs:
|
|
3006
|
+
print(f" {name}: {log_file}")
|
|
3007
|
+
|
|
3008
|
+
return 0
|
|
3009
|
+
|
|
3010
|
+
# Stop specific instance or self
|
|
3011
|
+
# Get instance name from injected session or target
|
|
3012
|
+
if session_id and not target:
|
|
3013
|
+
instance_name, _ = resolve_instance_name(session_id, get_config().tag)
|
|
3014
|
+
else:
|
|
3015
|
+
instance_name = target
|
|
3016
|
+
|
|
3017
|
+
position = load_instance_position(instance_name) if instance_name else None
|
|
3018
|
+
|
|
3019
|
+
if not instance_name:
|
|
3020
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
3021
|
+
print("Error: Cannot determine instance", file=sys.stderr)
|
|
3022
|
+
print("Usage: Prompt Claude to run 'hcom stop' (or directly use: hcom stop <alias> or hcom stop all)", file=sys.stderr)
|
|
3023
|
+
else:
|
|
3024
|
+
print("Error: Alias required", file=sys.stderr)
|
|
3025
|
+
print("Usage: hcom stop <alias>", file=sys.stderr)
|
|
3026
|
+
print(" Or: hcom stop all", file=sys.stderr)
|
|
3027
|
+
print(" Or: prompt claude to run 'hcom stop' on itself", file=sys.stderr)
|
|
3028
|
+
positions = load_all_positions()
|
|
3029
|
+
visible = [alias for alias, data in positions.items() if should_show_in_watch(data)]
|
|
3030
|
+
if visible:
|
|
3031
|
+
print(f"Active aliases: {', '.join(sorted(visible))}", file=sys.stderr)
|
|
3032
|
+
return 1
|
|
3033
|
+
|
|
3034
|
+
if not position:
|
|
3035
|
+
print(f"No instance found for {instance_name}")
|
|
3036
|
+
return 1
|
|
3037
|
+
|
|
3038
|
+
# Skip already stopped instances
|
|
3039
|
+
if not position.get('enabled', False):
|
|
3040
|
+
print(f"HCOM already stopped for {instance_name}")
|
|
3041
|
+
return 0
|
|
3042
|
+
|
|
3043
|
+
# Check if this is a subagent - disable all siblings
|
|
3044
|
+
if is_subagent_instance(position):
|
|
3045
|
+
parent_session_id = position.get('parent_session_id')
|
|
3046
|
+
positions = load_all_positions()
|
|
3047
|
+
disabled_count = 0
|
|
3048
|
+
disabled_names = []
|
|
3049
|
+
|
|
3050
|
+
for name, data in positions.items():
|
|
3051
|
+
if data.get('parent_session_id') == parent_session_id and data.get('enabled', False):
|
|
3052
|
+
# Set external stop flag (subagents can't self-stop, so any stop is external)
|
|
3053
|
+
update_instance_position(name, {'external_stop_pending': True})
|
|
3054
|
+
disable_instance(name)
|
|
3055
|
+
disabled_count += 1
|
|
3056
|
+
disabled_names.append(name)
|
|
3057
|
+
|
|
3058
|
+
if disabled_count > 0:
|
|
3059
|
+
print(f"Stopped {disabled_count} subagent(s): {', '.join(disabled_names)}")
|
|
3060
|
+
print("Note: All subagents of the same parent must be disabled together.")
|
|
3061
|
+
else:
|
|
3062
|
+
print(f"No enabled subagents found for {instance_name}")
|
|
3063
|
+
else:
|
|
3064
|
+
# Regular parent instance
|
|
3065
|
+
# External stop = CLI user specified target, Self stop = no target (uses session_id)
|
|
3066
|
+
is_external_stop = target is not None
|
|
3067
|
+
|
|
3068
|
+
if is_external_stop:
|
|
3069
|
+
# Set flag to notify instance via PostToolUse
|
|
3070
|
+
update_instance_position(instance_name, {'external_stop_pending': True})
|
|
3071
|
+
|
|
3072
|
+
disable_instance(instance_name)
|
|
3073
|
+
print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
|
|
3074
|
+
|
|
3075
|
+
# Show background log location if applicable
|
|
3076
|
+
if position.get('background'):
|
|
3077
|
+
log_file = position.get('background_log_file', '')
|
|
3078
|
+
if log_file:
|
|
3079
|
+
print(f"\nHeadless instance log: {log_file}")
|
|
3080
|
+
print(f"Monitor: tail -f {log_file}")
|
|
3081
|
+
|
|
3082
|
+
return 0
|
|
3083
|
+
|
|
3084
|
+
def cmd_start(argv: list[str]) -> int:
|
|
3085
|
+
"""Enable HCOM participation: hcom start [alias] [--_hcom_session ID]"""
|
|
3086
|
+
# Parse arguments
|
|
3087
|
+
target = None
|
|
3088
|
+
session_id = None
|
|
3089
|
+
|
|
3090
|
+
# Extract --_hcom_session if present
|
|
3091
|
+
if '--_hcom_session' in argv:
|
|
3092
|
+
idx = argv.index('--_hcom_session')
|
|
3093
|
+
if idx + 1 < len(argv):
|
|
3094
|
+
session_id = argv[idx + 1]
|
|
3095
|
+
argv = argv[:idx] + argv[idx + 2:]
|
|
3096
|
+
|
|
3097
|
+
# Extract --_hcom_sender if present (for subagents)
|
|
3098
|
+
sender_override = None
|
|
3099
|
+
if '--_hcom_sender' in argv:
|
|
3100
|
+
idx = argv.index('--_hcom_sender')
|
|
3101
|
+
if idx + 1 < len(argv):
|
|
3102
|
+
sender_override = argv[idx + 1]
|
|
3103
|
+
argv = argv[:idx] + argv[idx + 2:]
|
|
3104
|
+
|
|
3105
|
+
# Remove flags to get target
|
|
3106
|
+
args_without_flags = [a for a in argv if not a.startswith('--')]
|
|
3107
|
+
if args_without_flags:
|
|
3108
|
+
target = args_without_flags[0]
|
|
3109
|
+
|
|
3110
|
+
# SUBAGENT PATH: --_hcom_sender provided
|
|
3111
|
+
if sender_override:
|
|
3112
|
+
instance_data = load_instance_position(sender_override)
|
|
3113
|
+
if not instance_data or instance_data.get('status') == 'exited':
|
|
3114
|
+
print(f"Error: Instance '{sender_override}' not found or has exited", file=sys.stderr)
|
|
3115
|
+
return 1
|
|
3116
|
+
|
|
3117
|
+
enable_instance(sender_override)
|
|
3118
|
+
set_status(sender_override, 'active', 'start')
|
|
3119
|
+
print(f"HCOM started for {sender_override}")
|
|
3120
|
+
print(f"Send: hcom send 'message' --_hcom_sender {sender_override}")
|
|
3121
|
+
print(f"When finished working always run: hcom send done --_hcom_sender {sender_override}")
|
|
3122
|
+
return 0
|
|
3123
|
+
|
|
3124
|
+
# Get instance name from injected session or target
|
|
3125
|
+
if session_id and not target:
|
|
3126
|
+
instance_name, existing_data = resolve_instance_name(session_id, get_config().tag)
|
|
3127
|
+
|
|
3128
|
+
# Check for Task ambiguity (parent frozen, subagent calling)
|
|
3129
|
+
if existing_data and in_subagent_context(existing_data):
|
|
3130
|
+
# Get list of subagents from THIS Task execution that are disabled
|
|
3131
|
+
active_list = existing_data.get('current_subagents', [])
|
|
3132
|
+
positions = load_all_positions()
|
|
3133
|
+
subagent_ids = [
|
|
3134
|
+
sid for sid in active_list
|
|
3135
|
+
if sid in positions and not positions[sid].get('enabled', False)
|
|
3136
|
+
]
|
|
3137
|
+
|
|
3138
|
+
print("Task tool running - you must provide an alias")
|
|
3139
|
+
print("Use: hcom start --_hcom_sender {alias}")
|
|
3140
|
+
if subagent_ids:
|
|
3141
|
+
print(f"Choose from one of these valid aliases: {', '.join(subagent_ids)}")
|
|
3142
|
+
return 1
|
|
3143
|
+
|
|
3144
|
+
# Create instance if it doesn't exist (opt-in for vanilla instances)
|
|
3145
|
+
if not existing_data:
|
|
3146
|
+
initialize_instance_in_position_file(instance_name, session_id)
|
|
3147
|
+
# Enable instance (clears all stop flags)
|
|
3148
|
+
enable_instance(instance_name)
|
|
3149
|
+
print(f"\nStarted HCOM for {instance_name}")
|
|
3150
|
+
else:
|
|
3151
|
+
# Skip already started instances
|
|
3152
|
+
if existing_data.get('enabled', False):
|
|
3153
|
+
print(f"HCOM already started for {instance_name}")
|
|
3154
|
+
return 0
|
|
3155
|
+
|
|
3156
|
+
# Check if background instance has exited permanently
|
|
3157
|
+
if existing_data.get('session_ended') and existing_data.get('background'):
|
|
3158
|
+
session = existing_data.get('session_id', '')
|
|
3159
|
+
print(f"Cannot start {instance_name}: headless instance has exited permanently")
|
|
3160
|
+
print(f"Headless instances terminate when stopped and cannot be restarted")
|
|
3161
|
+
if session:
|
|
3162
|
+
print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
|
|
3163
|
+
return 1
|
|
3164
|
+
|
|
3165
|
+
# Re-enabling existing instance
|
|
3166
|
+
enable_instance(instance_name)
|
|
3167
|
+
print(f"Started HCOM for {instance_name}")
|
|
3168
|
+
|
|
3169
|
+
return 0
|
|
3170
|
+
|
|
3171
|
+
# CLI path: start specific instance
|
|
3172
|
+
positions = load_all_positions()
|
|
3173
|
+
|
|
3174
|
+
# Handle missing target from external CLI
|
|
3175
|
+
if not target:
|
|
3176
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
3177
|
+
print("Error: Cannot determine instance", file=sys.stderr)
|
|
3178
|
+
print("Usage: Prompt Claude to run 'hcom start' (or: hcom start <alias>)", file=sys.stderr)
|
|
3179
|
+
else:
|
|
3180
|
+
print("Error: Alias required", file=sys.stderr)
|
|
3181
|
+
print("Usage: hcom start <alias> (or: prompt claude to run 'hcom start')", file=sys.stderr)
|
|
3182
|
+
print("To launch new instances: hcom <count>", file=sys.stderr)
|
|
3183
|
+
return 1
|
|
3184
|
+
|
|
3185
|
+
# Start specific instance
|
|
3186
|
+
instance_name = target
|
|
3187
|
+
position = positions.get(instance_name)
|
|
3188
|
+
|
|
3189
|
+
if not position:
|
|
3190
|
+
print(f"Instance not found: {instance_name}")
|
|
3191
|
+
return 1
|
|
3192
|
+
|
|
3193
|
+
# Skip already started instances
|
|
3194
|
+
if position.get('enabled', False):
|
|
3195
|
+
print(f"HCOM already started for {instance_name}")
|
|
3196
|
+
return 0
|
|
3197
|
+
|
|
3198
|
+
# Check if background instance has exited permanently
|
|
3199
|
+
if position.get('session_ended') and position.get('background'):
|
|
3200
|
+
session = position.get('session_id', '')
|
|
3201
|
+
print(f"Cannot start {instance_name}: headless instance has exited permanently")
|
|
3202
|
+
print(f"Headless instances terminate when stopped and cannot be restarted")
|
|
3203
|
+
if session:
|
|
3204
|
+
print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
|
|
3205
|
+
return 1
|
|
3206
|
+
|
|
3207
|
+
# Enable instance (clears all stop flags)
|
|
3208
|
+
enable_instance(instance_name)
|
|
3209
|
+
|
|
3210
|
+
print(f"Started HCOM for {instance_name}")
|
|
3211
|
+
return 0
|
|
3212
|
+
|
|
3213
|
+
def cmd_reset(argv: list[str]) -> int:
|
|
3214
|
+
"""Reset HCOM components: logs, hooks, config
|
|
3215
|
+
Usage:
|
|
3216
|
+
hcom reset # Everything (stop all + logs + hooks + config)
|
|
3217
|
+
hcom reset logs # Archive conversation only
|
|
3218
|
+
hcom reset hooks # Remove hooks only
|
|
3219
|
+
hcom reset config # Clear config (backup to config.env.TIMESTAMP)
|
|
3220
|
+
hcom reset logs hooks # Combine targets
|
|
3221
|
+
"""
|
|
3222
|
+
# No args = everything
|
|
3223
|
+
do_everything = not argv
|
|
3224
|
+
targets = argv if argv else ['logs', 'hooks', 'config']
|
|
3225
|
+
|
|
3226
|
+
# Validate targets
|
|
3227
|
+
valid = {'logs', 'hooks', 'config'}
|
|
3228
|
+
invalid = [t for t in targets if t not in valid]
|
|
3229
|
+
if invalid:
|
|
3230
|
+
print(f"Invalid target(s): {', '.join(invalid)}", file=sys.stderr)
|
|
3231
|
+
print("Valid targets: logs, hooks, config", file=sys.stderr)
|
|
3232
|
+
return 1
|
|
3233
|
+
|
|
3234
|
+
exit_codes = []
|
|
3235
|
+
|
|
3236
|
+
# Stop all instances if doing everything
|
|
3237
|
+
if do_everything:
|
|
3238
|
+
exit_codes.append(cmd_stop(['all']))
|
|
3239
|
+
|
|
3240
|
+
# Execute based on targets
|
|
3241
|
+
if 'logs' in targets:
|
|
3242
|
+
exit_codes.append(clear())
|
|
3243
|
+
|
|
3244
|
+
if 'hooks' in targets:
|
|
3245
|
+
exit_codes.append(cleanup('--all'))
|
|
3246
|
+
if remove_global_hooks():
|
|
3247
|
+
print("Removed hooks")
|
|
3248
|
+
else:
|
|
3249
|
+
print("Warning: Could not remove hooks. Check your claude settings.json file it might be invalid", file=sys.stderr)
|
|
3250
|
+
exit_codes.append(1)
|
|
3251
|
+
|
|
3252
|
+
if 'config' in targets:
|
|
3253
|
+
config_path = hcom_path(CONFIG_FILE)
|
|
3254
|
+
if config_path.exists():
|
|
3255
|
+
# Backup with timestamp
|
|
3256
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
3257
|
+
backup_path = hcom_path(f'config.env.{timestamp}')
|
|
3258
|
+
shutil.copy2(config_path, backup_path)
|
|
3259
|
+
config_path.unlink()
|
|
3260
|
+
print(f"Config backed up to config.env.{timestamp} and cleared")
|
|
3261
|
+
exit_codes.append(0)
|
|
3262
|
+
else:
|
|
3263
|
+
print("No config file to clear")
|
|
3264
|
+
exit_codes.append(0)
|
|
3265
|
+
|
|
3266
|
+
return max(exit_codes) if exit_codes else 0
|
|
3267
|
+
|
|
3268
|
+
def cleanup(*args: str) -> int:
|
|
3269
|
+
"""Remove hcom hooks from current directory or all directories"""
|
|
3270
|
+
if args and args[0] == '--all':
|
|
3271
|
+
directories = set()
|
|
3272
|
+
|
|
3273
|
+
# Get all directories from current instances
|
|
3274
|
+
try:
|
|
3275
|
+
positions = load_all_positions()
|
|
3276
|
+
if positions:
|
|
3277
|
+
for instance_data in positions.values():
|
|
3278
|
+
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
3279
|
+
directories.add(instance_data['directory'])
|
|
3280
|
+
except Exception as e:
|
|
3281
|
+
print(f"Warning: Could not read current instances: {e}")
|
|
3282
|
+
|
|
3283
|
+
# Also check archived instances for directories (until 0.5.0)
|
|
3284
|
+
try:
|
|
3285
|
+
archive_dir = hcom_path(ARCHIVE_DIR)
|
|
3286
|
+
if archive_dir.exists():
|
|
3287
|
+
for session_dir in archive_dir.iterdir():
|
|
3288
|
+
if session_dir.is_dir() and session_dir.name.startswith('session-'):
|
|
3289
|
+
instances_dir = session_dir / 'instances'
|
|
3290
|
+
if instances_dir.exists():
|
|
3291
|
+
for instance_file in instances_dir.glob('*.json'):
|
|
3292
|
+
try:
|
|
3293
|
+
data = json.loads(instance_file.read_text())
|
|
3294
|
+
if 'directory' in data:
|
|
3295
|
+
directories.add(data['directory'])
|
|
3296
|
+
except Exception:
|
|
3297
|
+
pass
|
|
3298
|
+
except Exception as e:
|
|
3299
|
+
print(f"Warning: Could not read archived instances: {e}")
|
|
3300
|
+
|
|
3301
|
+
if not directories:
|
|
3302
|
+
print("No directories found in current HCOM tracking")
|
|
3303
|
+
return 0
|
|
3304
|
+
|
|
3305
|
+
print(f"Found {len(directories)} unique directories to check")
|
|
3306
|
+
cleaned = 0
|
|
3307
|
+
failed = 0
|
|
3308
|
+
already_clean = 0
|
|
3309
|
+
|
|
3310
|
+
for directory in sorted(directories):
|
|
3311
|
+
# Check if directory exists
|
|
3312
|
+
if not Path(directory).exists():
|
|
3313
|
+
print(f"\nSkipping {directory} (directory no longer exists)")
|
|
3314
|
+
continue
|
|
3315
|
+
|
|
3316
|
+
print(f"\nChecking {directory}...")
|
|
3317
|
+
|
|
3318
|
+
exit_code, message = cleanup_directory_hooks(Path(directory))
|
|
3319
|
+
if exit_code == 0:
|
|
3320
|
+
if "No hcom hooks found" in message or "No Claude settings found" in message:
|
|
3321
|
+
already_clean += 1
|
|
3322
|
+
print(f" {message}")
|
|
3323
|
+
else:
|
|
3324
|
+
cleaned += 1
|
|
3325
|
+
print(f" {message}")
|
|
3326
|
+
else:
|
|
3327
|
+
failed += 1
|
|
3328
|
+
print(f" {message}")
|
|
3329
|
+
|
|
3330
|
+
print(f"\nSummary:")
|
|
3331
|
+
print(f" Cleaned: {cleaned} directories")
|
|
3332
|
+
print(f" Already clean: {already_clean} directories")
|
|
3333
|
+
if failed > 0:
|
|
3334
|
+
print(f" Failed: {failed} directories")
|
|
3335
|
+
return 1
|
|
3336
|
+
return 0
|
|
3337
|
+
|
|
3338
|
+
else:
|
|
3339
|
+
exit_code, message = cleanup_directory_hooks(Path.cwd())
|
|
3340
|
+
print(message)
|
|
3341
|
+
return exit_code
|
|
3342
|
+
|
|
3343
|
+
def ensure_hooks_current() -> bool:
|
|
3344
|
+
"""Ensure hooks match current execution context - called on EVERY command.
|
|
3345
|
+
Auto-updates hooks if execution context changes (e.g., pip → uvx).
|
|
3346
|
+
Always returns True (warns but never blocks - Claude Code is fault-tolerant)."""
|
|
3347
|
+
|
|
3348
|
+
# Verify hooks exist and match current execution context
|
|
3349
|
+
global_settings = get_claude_settings_path()
|
|
3350
|
+
|
|
3351
|
+
# Check if hooks are valid (exist + env var matches current context)
|
|
3352
|
+
hooks_exist = verify_hooks_installed(global_settings)
|
|
3353
|
+
env_var_matches = False
|
|
3354
|
+
|
|
3355
|
+
if hooks_exist:
|
|
3356
|
+
try:
|
|
3357
|
+
settings = load_settings_json(global_settings, default={})
|
|
3358
|
+
if settings is None:
|
|
3359
|
+
settings = {}
|
|
3360
|
+
current_hcom = _build_hcom_env_value()
|
|
3361
|
+
installed_hcom = settings.get('env', {}).get('HCOM')
|
|
3362
|
+
env_var_matches = (installed_hcom == current_hcom)
|
|
3363
|
+
except Exception:
|
|
3364
|
+
# Failed to read settings - try to fix by updating
|
|
3365
|
+
env_var_matches = False
|
|
3366
|
+
|
|
3367
|
+
# Install/update hooks if missing or env var wrong
|
|
3368
|
+
if not hooks_exist or not env_var_matches:
|
|
3369
|
+
try:
|
|
3370
|
+
setup_hooks()
|
|
3371
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
3372
|
+
print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
|
|
3373
|
+
print("=" * 60, file=sys.stderr)
|
|
3374
|
+
except Exception as e:
|
|
3375
|
+
# Failed to verify/update hooks, but they might still work
|
|
3376
|
+
# Claude Code is fault-tolerant with malformed JSON
|
|
3377
|
+
print(f"⚠️ Could not verify/update hooks: {e}", file=sys.stderr)
|
|
3378
|
+
print("If HCOM doesn't work, check ~/.claude/settings.json", file=sys.stderr)
|
|
3379
|
+
|
|
3380
|
+
return True
|
|
3381
|
+
|
|
3382
|
+
def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> int:
|
|
3383
|
+
"""Send message to hcom: hcom send "message" [--_hcom_session ID] [--_hcom_sender NAME]"""
|
|
3384
|
+
# Parse message and session_id
|
|
3385
|
+
message = None
|
|
3386
|
+
session_id = None
|
|
3387
|
+
subagent_id = None
|
|
3388
|
+
|
|
3389
|
+
# Extract --_hcom_sender if present (for subagents)
|
|
3390
|
+
if '--_hcom_sender' in argv:
|
|
3391
|
+
idx = argv.index('--_hcom_sender')
|
|
3392
|
+
if idx + 1 < len(argv):
|
|
3393
|
+
subagent_id = argv[idx + 1]
|
|
3394
|
+
argv = argv[:idx] + argv[idx + 2:] # Remove flag and value
|
|
3395
|
+
|
|
3396
|
+
# Extract --_hcom_session if present (injected by PreToolUse hook)
|
|
3397
|
+
if '--_hcom_session' in argv:
|
|
3398
|
+
idx = argv.index('--_hcom_session')
|
|
3399
|
+
if idx + 1 < len(argv):
|
|
3400
|
+
session_id = argv[idx + 1]
|
|
3401
|
+
argv = argv[:idx] + argv[idx + 2:] # Remove flag and value
|
|
3402
|
+
|
|
3403
|
+
# First non-flag argument is the message
|
|
3404
|
+
if argv:
|
|
3405
|
+
message = argv[0]
|
|
3406
|
+
|
|
3407
|
+
# Check message is provided
|
|
3408
|
+
if not message:
|
|
3409
|
+
print(format_error("No message provided"), file=sys.stderr)
|
|
3410
|
+
return 1
|
|
3411
|
+
|
|
3412
|
+
# Check if hcom files exist
|
|
3413
|
+
log_file = hcom_path(LOG_FILE)
|
|
3414
|
+
instances_dir = hcom_path(INSTANCES_DIR)
|
|
3415
|
+
|
|
3416
|
+
if not log_file.exists() and not instances_dir.exists():
|
|
3417
|
+
print(format_error("No conversation found", "Run 'hcom <count>' first"), file=sys.stderr)
|
|
3418
|
+
return 1
|
|
3419
|
+
|
|
3420
|
+
# Validate message
|
|
3421
|
+
error = validate_message(message)
|
|
3422
|
+
if error:
|
|
3423
|
+
print(error, file=sys.stderr)
|
|
3424
|
+
return 1
|
|
3425
|
+
|
|
3426
|
+
# Check for unmatched mentions (minimal warning)
|
|
3427
|
+
mentions = MENTION_PATTERN.findall(message)
|
|
3428
|
+
if mentions:
|
|
3429
|
+
try:
|
|
3430
|
+
positions = load_all_positions()
|
|
3431
|
+
all_instances = list(positions.keys())
|
|
3432
|
+
sender_name = SENDER
|
|
3433
|
+
all_names = all_instances + [sender_name]
|
|
3434
|
+
unmatched = [m for m in mentions
|
|
3435
|
+
if not any(name.lower().startswith(m.lower()) for name in all_names)]
|
|
3436
|
+
if unmatched:
|
|
3437
|
+
print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
|
|
3438
|
+
except Exception:
|
|
3439
|
+
pass # Don't fail on warning
|
|
3440
|
+
|
|
3441
|
+
# Determine sender from injected flags or CLI
|
|
3442
|
+
if session_id and not force_cli:
|
|
3443
|
+
# Instance context - use sender override if provided (subagent), otherwise resolve from session_id
|
|
3444
|
+
if subagent_id: #subagent id is same as subagent name
|
|
3445
|
+
sender_name = subagent_id
|
|
3446
|
+
instance_data = load_instance_position(sender_name)
|
|
3447
|
+
if not instance_data:
|
|
3448
|
+
print(format_error(f"Subagent instance file missing for {subagent_id}"), file=sys.stderr)
|
|
3449
|
+
return 1
|
|
3450
|
+
else:
|
|
3451
|
+
# Normal instance - resolve name from session_id
|
|
3452
|
+
try:
|
|
3453
|
+
sender_name, instance_data = resolve_instance_name(session_id, get_config().tag)
|
|
3454
|
+
except (ValueError, Exception) as e:
|
|
3455
|
+
print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
|
|
3456
|
+
return 1
|
|
3457
|
+
|
|
3458
|
+
# Initialize instance if doesn't exist (first use)
|
|
3459
|
+
if not instance_data:
|
|
3460
|
+
initialize_instance_in_position_file(sender_name, session_id)
|
|
3461
|
+
instance_data = load_instance_position(sender_name)
|
|
3462
|
+
|
|
3463
|
+
# Guard: If parent is in subagent context, subagent MUST provide --_hcom_sender
|
|
3464
|
+
if in_subagent_context(instance_data):
|
|
3465
|
+
# Get list of active subagents for helpful error message
|
|
3466
|
+
positions = load_all_positions()
|
|
3467
|
+
subagent_ids = [name for name in positions if name.startswith(f"{sender_name}_")]
|
|
3468
|
+
|
|
3469
|
+
suggestion = f"Use: hcom send 'message' --_hcom_sender {{alias}}"
|
|
3470
|
+
if subagent_ids:
|
|
3471
|
+
suggestion += f". Valid aliases: {', '.join(subagent_ids)}"
|
|
3472
|
+
|
|
3473
|
+
print(format_error("Task tool subagent must provide sender identity", suggestion), file=sys.stderr)
|
|
3474
|
+
return 1
|
|
3475
|
+
|
|
3476
|
+
# Check enabled state
|
|
3477
|
+
if not instance_data.get('enabled', False):
|
|
3478
|
+
previously_enabled = instance_data.get('previously_enabled', False)
|
|
3479
|
+
if previously_enabled:
|
|
3480
|
+
# Was enabled, now disabled - don't suggest re-enabling
|
|
3481
|
+
print(format_error("HCOM stopped. Cannot send messages."), file=sys.stderr)
|
|
3482
|
+
else:
|
|
3483
|
+
# Never enabled - helpful message
|
|
3484
|
+
print(format_error("HCOM not started for this instance. To send a message first run: 'hcom start' then use hcom send"), file=sys.stderr)
|
|
3485
|
+
return 1
|
|
3486
|
+
|
|
3487
|
+
# Handle "done" command - subagent finished work, wait for messages (control command)
|
|
3488
|
+
if message == "done" and subagent_id:
|
|
3489
|
+
# Control command - don't write to log, PostToolUse will handle polling
|
|
3490
|
+
print(f"Subagent {subagent_id}: Waiting for messages...", file=sys.stderr)
|
|
3491
|
+
return 0
|
|
3492
|
+
|
|
3493
|
+
# Set status to active for subagents (identity confirmed, enabled verified)
|
|
3494
|
+
if subagent_id:
|
|
3495
|
+
set_status(subagent_id, 'active', 'send')
|
|
3496
|
+
|
|
3497
|
+
# Send message
|
|
3498
|
+
if not send_message(sender_name, message):
|
|
3499
|
+
print(format_error("Failed to send message"), file=sys.stderr)
|
|
3500
|
+
return 1
|
|
3501
|
+
|
|
3502
|
+
# Show unread messages, grouped by subagent vs main
|
|
3503
|
+
messages = get_unread_messages(sender_name, update_position=True)
|
|
3504
|
+
if messages:
|
|
3505
|
+
# Separate subagent messages from main messages
|
|
3506
|
+
subagent_msgs = []
|
|
3507
|
+
main_msgs = []
|
|
3508
|
+
for msg in messages:
|
|
3509
|
+
sender = msg['from']
|
|
3510
|
+
# Check if sender is a subagent of this instance
|
|
3511
|
+
if sender.startswith(f"{sender_name}_") and sender != sender_name:
|
|
3512
|
+
subagent_msgs.append(msg)
|
|
3513
|
+
else:
|
|
3514
|
+
main_msgs.append(msg)
|
|
3515
|
+
|
|
3516
|
+
output_parts = ["Message sent"]
|
|
3517
|
+
max_msgs = MAX_MESSAGES_PER_DELIVERY
|
|
3518
|
+
|
|
3519
|
+
if main_msgs:
|
|
3520
|
+
formatted = format_hook_messages(main_msgs[:max_msgs], sender_name)
|
|
3521
|
+
output_parts.append(f"\n{formatted}")
|
|
3522
|
+
|
|
3523
|
+
if subagent_msgs:
|
|
3524
|
+
formatted = format_hook_messages(subagent_msgs[:max_msgs], sender_name)
|
|
3525
|
+
output_parts.append(f"\n[Subagent messages]\n{formatted}")
|
|
3526
|
+
|
|
3527
|
+
print("".join(output_parts), file=sys.stderr)
|
|
3528
|
+
else:
|
|
3529
|
+
print("Message sent", file=sys.stderr)
|
|
3530
|
+
|
|
3531
|
+
return 0
|
|
3532
|
+
else:
|
|
3533
|
+
# CLI context - no session_id or force_cli=True
|
|
3534
|
+
|
|
3535
|
+
# Warn if inside Claude Code but no session_id (hooks not working(?))
|
|
3536
|
+
if os.environ.get('CLAUDECODE') == '1' and not session_id and not force_cli:
|
|
3537
|
+
if subagent_id:
|
|
3538
|
+
# Subagent command not auto-approved
|
|
3539
|
+
print(format_error(
|
|
3540
|
+
"Cannot determine alias - hcom command not auto-approved",
|
|
3541
|
+
"Run hcom commands directly with correct syntax: 'hcom send 'message' --_hcom_sender {alias}'"
|
|
3542
|
+
), file=sys.stderr)
|
|
3543
|
+
return 1
|
|
3544
|
+
else:
|
|
3545
|
+
print(f"⚠️ Cannot determine alias - message sent as '{SENDER}'", file=sys.stderr)
|
|
3546
|
+
|
|
3547
|
+
|
|
3548
|
+
sender_name = SENDER
|
|
3549
|
+
|
|
3550
|
+
if not send_message(sender_name, message):
|
|
3551
|
+
print(format_error("Failed to send message"), file=sys.stderr)
|
|
3552
|
+
return 1
|
|
3553
|
+
|
|
3554
|
+
if not quiet:
|
|
3555
|
+
print(f"✓ Sent from {sender_name}", file=sys.stderr)
|
|
3556
|
+
|
|
3557
|
+
return 0
|
|
3558
|
+
|
|
3559
|
+
def send_cli(message: str, quiet: bool = False) -> int:
|
|
3560
|
+
"""Force CLI sender (skip outbox, use config sender name)"""
|
|
3561
|
+
return cmd_send([message], force_cli=True, quiet=quiet)
|
|
3562
|
+
|
|
3563
|
+
# ==================== Hook Helpers ====================
|
|
3564
|
+
|
|
3565
|
+
def format_subagent_hcom_instructions(alias: str) -> str:
|
|
3566
|
+
"""Format HCOM usage instructions for subagents"""
|
|
3567
|
+
hcom_cmd = build_hcom_command()
|
|
3568
|
+
|
|
3569
|
+
# Add command override notice if not using short form
|
|
3570
|
+
command_notice = ""
|
|
3571
|
+
if hcom_cmd != "hcom":
|
|
3572
|
+
command_notice = f"""IMPORTANT:
|
|
3573
|
+
The hcom command in this environment is: {hcom_cmd}
|
|
3574
|
+
Replace all mentions of "hcom" below with this command.
|
|
3575
|
+
|
|
3576
|
+
"""
|
|
3577
|
+
|
|
3578
|
+
return f"""{command_notice}[HCOM INFORMATION]
|
|
3579
|
+
Your HCOM alias is: {alias}
|
|
3580
|
+
HCOM is a communication tool.
|
|
3581
|
+
|
|
3582
|
+
- To Send a message, run:
|
|
3583
|
+
hcom send 'your message' --_hcom_sender {alias}
|
|
3584
|
+
(use '@alias' for direct messages)
|
|
3585
|
+
|
|
3586
|
+
- Messages are delivered automatically via bash feedback or hooks.
|
|
3587
|
+
There is no way to proactively check or poll for messages yourself.
|
|
3588
|
+
|
|
3589
|
+
- When finished working, always run:
|
|
3590
|
+
hcom send done --_hcom_sender {alias}
|
|
3591
|
+
|
|
3592
|
+
- {{"decision": "block"}} text is normal operation
|
|
3593
|
+
- Prioritize @{SENDER} over other participants
|
|
3594
|
+
- First action: Announce your online presence to @{SENDER}
|
|
3595
|
+
------"""
|
|
3596
|
+
|
|
3597
|
+
def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
|
|
3598
|
+
"""Format messages for hook feedback"""
|
|
3599
|
+
if len(messages) == 1:
|
|
3600
|
+
msg = messages[0]
|
|
3601
|
+
reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
|
|
3602
|
+
else:
|
|
3603
|
+
parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
|
|
3604
|
+
reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
|
|
3605
|
+
|
|
3606
|
+
# Only append hints to messages
|
|
3607
|
+
hints = get_config().hints
|
|
3608
|
+
if hints:
|
|
3609
|
+
reason = f"{reason} | [{hints}]"
|
|
3610
|
+
|
|
3611
|
+
return reason
|
|
3612
|
+
|
|
3613
|
+
# ==================== Hook Handlers ====================
|
|
3614
|
+
|
|
3615
|
+
def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -> tuple[str, dict[str, Any], bool]:
|
|
3616
|
+
"""
|
|
3617
|
+
Initialize instance context. Flow:
|
|
3618
|
+
1. Resolve instance name (search by session_id, generate if not found)
|
|
3619
|
+
2. Create instance file if fresh start in UserPromptSubmit
|
|
3620
|
+
3. Build updates dict
|
|
3621
|
+
4. Return (instance_name, updates, is_matched_resume)
|
|
3622
|
+
"""
|
|
3623
|
+
session_id = hook_data.get('session_id', '')
|
|
3624
|
+
transcript_path = hook_data.get('transcript_path', '')
|
|
3625
|
+
tag = get_config().tag
|
|
3626
|
+
|
|
3627
|
+
# Resolve instance name - existing_data is None for fresh starts
|
|
3628
|
+
instance_name, existing_data = resolve_instance_name(session_id, tag)
|
|
3629
|
+
|
|
3630
|
+
# Save migrated data if we have it
|
|
3631
|
+
if existing_data:
|
|
3632
|
+
save_instance_position(instance_name, existing_data)
|
|
3633
|
+
|
|
3634
|
+
# Create instance file if fresh start in UserPromptSubmit
|
|
3635
|
+
if existing_data is None and hook_type == 'userpromptsubmit':
|
|
3636
|
+
initialize_instance_in_position_file(instance_name, session_id)
|
|
3637
|
+
|
|
3638
|
+
# Build updates dict
|
|
3639
|
+
updates: dict[str, Any] = {
|
|
3640
|
+
'directory': str(Path.cwd()),
|
|
3641
|
+
'tag': tag,
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
if session_id:
|
|
3645
|
+
updates['session_id'] = session_id
|
|
3646
|
+
|
|
3647
|
+
if transcript_path:
|
|
3648
|
+
updates['transcript_path'] = transcript_path
|
|
3649
|
+
|
|
3650
|
+
bg_env = os.environ.get('HCOM_BACKGROUND')
|
|
3651
|
+
if bg_env:
|
|
3652
|
+
updates['background'] = True
|
|
3653
|
+
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
3654
|
+
|
|
3655
|
+
# Simple boolean: matched resume if existing_data found
|
|
3656
|
+
is_matched_resume = (existing_data is not None)
|
|
3657
|
+
|
|
3658
|
+
return instance_name, updates, is_matched_resume
|
|
3659
|
+
|
|
3660
|
+
def is_safe_hcom_command(command: str) -> bool:
|
|
3661
|
+
"""Security check: verify ALL parts of chained command are hcom commands"""
|
|
3662
|
+
# Strip quoted strings, split on &&/||/;, check all parts match hcom pattern
|
|
3663
|
+
cmd = re.sub(r'''(["'])(?:(?=(\\?))\2.)*?\1''', '', command)
|
|
3664
|
+
parts = [p.strip() for p in re.split(r'\s*(?:&&|\|\||;)\s*', cmd) if p.strip()]
|
|
3665
|
+
return bool(parts) and all(HCOM_COMMAND_PATTERN.match(p) for p in parts)
|
|
3666
|
+
|
|
3667
|
+
def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
3668
|
+
"""Handle PreToolUse hook - check force_closed, inject session_id, inject subagent identity"""
|
|
3669
|
+
instance_data = load_instance_position(instance_name)
|
|
3670
|
+
tool_name = hook_data.get('tool_name', '')
|
|
3671
|
+
session_id = hook_data.get('session_id', '')
|
|
3672
|
+
|
|
3673
|
+
# Record status for tool execution tracking (only if enabled)
|
|
3674
|
+
# Skip if --_hcom_sender present (subagent status set in cmd_send/cmd_start instead)
|
|
3675
|
+
if instance_data.get('enabled', False):
|
|
3676
|
+
has_sender_flag = False
|
|
3677
|
+
if tool_name == 'Bash':
|
|
3678
|
+
command = hook_data.get('tool_input', {}).get('command', '')
|
|
3679
|
+
has_sender_flag = '--_hcom_sender' in command
|
|
3680
|
+
|
|
3681
|
+
if not has_sender_flag:
|
|
3682
|
+
if in_subagent_context(instance_data):
|
|
3683
|
+
# In Task - update only subagents in current_subagents list
|
|
3684
|
+
current_list = instance_data.get('current_subagents', [])
|
|
3685
|
+
for subagent_id in current_list:
|
|
3686
|
+
set_status(subagent_id, 'active', tool_name)
|
|
3687
|
+
else:
|
|
3688
|
+
# Not in Task - update parent only
|
|
3689
|
+
set_status(instance_name, 'active', tool_name)
|
|
3690
|
+
|
|
3691
|
+
|
|
3692
|
+
# Inject session_id into hcom commands via updatedInput
|
|
3693
|
+
if tool_name == 'Bash' and session_id:
|
|
3694
|
+
command = hook_data.get('tool_input', {}).get('command', '')
|
|
3695
|
+
|
|
3696
|
+
# Match hcom commands for session_id injection and auto-approval
|
|
3697
|
+
matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
|
|
3698
|
+
if matches and is_safe_hcom_command(command):
|
|
3699
|
+
# Security validated: ALL command parts are hcom commands
|
|
3700
|
+
# Inject all if chained (&&, ||, ;, |), otherwise first only (avoids quoted text in messages)
|
|
3701
|
+
inject_all = len(matches) > 1 and any(op in command[matches[0].end():matches[1].start()] for op in ['&&', '||', ';', '|'])
|
|
3702
|
+
modified_command = HCOM_COMMAND_PATTERN.sub(rf'\g<0> --_hcom_session {session_id}', command, count=0 if inject_all else 1)
|
|
3703
|
+
|
|
3704
|
+
output = {
|
|
3705
|
+
"hookSpecificOutput": {
|
|
3706
|
+
"hookEventName": "PreToolUse",
|
|
3707
|
+
"permissionDecision": "allow",
|
|
3708
|
+
"updatedInput": {
|
|
3709
|
+
"command": modified_command
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
3714
|
+
sys.exit(0)
|
|
3715
|
+
|
|
3716
|
+
# Subagent identity injection for Task tool (only if HCOM enabled)
|
|
3717
|
+
if tool_name == 'Task':
|
|
3718
|
+
tool_input = hook_data.get('tool_input', {})
|
|
3719
|
+
subagent_type = tool_input.get('subagent_type', 'unknown')
|
|
3720
|
+
|
|
3721
|
+
# Check for resume parameter and look up existing HCOM ID
|
|
3722
|
+
resume_agent_id = tool_input.get('resume')
|
|
3723
|
+
existing_hcom_id = None
|
|
3724
|
+
|
|
3725
|
+
if resume_agent_id:
|
|
3726
|
+
# Look up existing HCOM ID for this agentId (reverse lookup)
|
|
3727
|
+
mappings = instance_data.get('subagent_mappings', {}) if instance_data else {}
|
|
3728
|
+
for hcom_id, agent_id in mappings.items():
|
|
3729
|
+
if agent_id == resume_agent_id:
|
|
3730
|
+
existing_hcom_id = hcom_id
|
|
3731
|
+
break
|
|
3732
|
+
|
|
3733
|
+
# Generate or reuse subagent ID
|
|
3734
|
+
parent_enabled = instance_data.get('enabled', False)
|
|
3735
|
+
|
|
3736
|
+
if existing_hcom_id:
|
|
3737
|
+
# Resuming: reuse existing HCOM ID
|
|
3738
|
+
subagent_id = existing_hcom_id
|
|
3739
|
+
subagent_file = hcom_path(INSTANCES_DIR, f"{subagent_id}.json")
|
|
3740
|
+
|
|
3741
|
+
# Reinitialize instance file (may have been cleaned up)
|
|
3742
|
+
if not subagent_file.exists():
|
|
3743
|
+
initialize_instance_in_position_file(subagent_id, parent_session_id=session_id, enabled=parent_enabled)
|
|
3744
|
+
else:
|
|
3745
|
+
# File exists: inherit parent's enabled state (overwrite disabled from cleanup)
|
|
3746
|
+
update_instance_position(subagent_id, {'enabled': parent_enabled})
|
|
3747
|
+
else:
|
|
3748
|
+
# New subagent: generate unique ID with atomic collision detection (parallel-safe)
|
|
3749
|
+
count = 1
|
|
3750
|
+
|
|
3751
|
+
for _ in range(1000):
|
|
3752
|
+
subagent_id = f"{instance_name}_{subagent_type}_{count}"
|
|
3753
|
+
|
|
3754
|
+
subagent_file = hcom_path(INSTANCES_DIR, f"{subagent_id}.json")
|
|
3755
|
+
|
|
3756
|
+
# Atomic test-and-create using O_EXCL (cross-platform)
|
|
3757
|
+
try:
|
|
3758
|
+
subagent_file.touch(exist_ok=False) # Fails if file exists
|
|
3759
|
+
break
|
|
3760
|
+
except FileExistsError:
|
|
3761
|
+
count += 1
|
|
3762
|
+
continue
|
|
3763
|
+
|
|
3764
|
+
# Initialize the reserved file (inherit parent's enabled state)
|
|
3765
|
+
initialize_instance_in_position_file(subagent_id, parent_session_id=session_id, enabled=parent_enabled)
|
|
3766
|
+
|
|
3767
|
+
# Add subagent to parent's current list
|
|
3768
|
+
current_subagents = instance_data.get('current_subagents', [])
|
|
3769
|
+
if subagent_id not in current_subagents:
|
|
3770
|
+
current_subagents.append(subagent_id)
|
|
3771
|
+
update_instance_position(instance_name, {'current_subagents': current_subagents})
|
|
3772
|
+
|
|
3773
|
+
# Detect HCOM intent in prompt
|
|
3774
|
+
hcom_detected = 'hcom' in tool_input.get('prompt', '').lower()
|
|
3775
|
+
|
|
3776
|
+
original_prompt = tool_input.get('prompt', '')
|
|
3777
|
+
|
|
3778
|
+
# Conditional prompt injection based on parent state and detection
|
|
3779
|
+
if parent_enabled:
|
|
3780
|
+
# Full bootstrap (parent enabled)
|
|
3781
|
+
modified_prompt = f"""{format_subagent_hcom_instructions(subagent_id)}
|
|
3782
|
+
|
|
3783
|
+
{original_prompt}"""
|
|
3784
|
+
elif hcom_detected:
|
|
3785
|
+
# Minimal opt-in (parent disabled + 'hcom' detected)
|
|
3786
|
+
hcom_cmd = build_hcom_command()
|
|
3787
|
+
modified_prompt = f"""[To use hcom run this command: '{hcom_cmd} start --_hcom_sender {subagent_id}']
|
|
3788
|
+
|
|
3789
|
+
{original_prompt}"""
|
|
3790
|
+
else:
|
|
3791
|
+
# Silent opt-in (parent disabled + no 'hcom')
|
|
3792
|
+
modified_prompt = original_prompt
|
|
3793
|
+
|
|
3794
|
+
# Preserve all original tool_input fields, only modify prompt
|
|
3795
|
+
updated_input = tool_input.copy()
|
|
3796
|
+
updated_input['prompt'] = modified_prompt
|
|
3797
|
+
|
|
3798
|
+
# Inject modified prompt and allow Task to proceed
|
|
3799
|
+
output = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": updated_input}}
|
|
3800
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
3801
|
+
sys.exit(0)
|
|
3802
|
+
|
|
3803
|
+
|
|
3804
|
+
def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3805
|
+
"""Handle Stop hook - poll for messages and deliver"""
|
|
3806
|
+
|
|
3807
|
+
try:
|
|
3808
|
+
updates['last_stop'] = time.time()
|
|
3809
|
+
timeout = get_config().timeout
|
|
3810
|
+
updates['wait_timeout'] = timeout
|
|
3811
|
+
set_status(instance_name, 'waiting')
|
|
3812
|
+
|
|
3813
|
+
# Disable orphaned subagents (backup cleanup if UserPromptSubmit missed them)
|
|
3814
|
+
if instance_data:
|
|
3815
|
+
positions = load_all_positions()
|
|
3816
|
+
for name, pos_data in positions.items():
|
|
3817
|
+
if name.startswith(f"{instance_name}_"):
|
|
3818
|
+
disable_instance(name)
|
|
3819
|
+
# Only set exited if not already exited
|
|
3820
|
+
current_status = pos_data.get('status', 'unknown')
|
|
3821
|
+
if current_status != 'exited':
|
|
3822
|
+
set_status(name, 'exited', 'orphaned')
|
|
3823
|
+
# Clear active subagents list if set
|
|
3824
|
+
if in_subagent_context(instance_data):
|
|
3825
|
+
updates['current_subagents'] = []
|
|
3826
|
+
|
|
3827
|
+
# Setup TCP notify listener (best effort)
|
|
3828
|
+
import socket, select
|
|
3829
|
+
notify_server = None
|
|
3830
|
+
try:
|
|
3831
|
+
notify_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
3832
|
+
notify_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
3833
|
+
notify_server.bind(('127.0.0.1', 0))
|
|
3834
|
+
notify_server.listen(128)
|
|
3835
|
+
notify_server.setblocking(False)
|
|
3836
|
+
notify_port = notify_server.getsockname()[1]
|
|
3837
|
+
updates['notify_port'] = notify_port
|
|
3838
|
+
updates['tcp_mode'] = True # Declare TCP mode active
|
|
3839
|
+
except Exception as e:
|
|
3840
|
+
log_hook_error('stop:notify_setup', e)
|
|
3841
|
+
# notify_server stays None - will fall back to polling
|
|
3842
|
+
updates['tcp_mode'] = False # Declare fallback mode
|
|
3843
|
+
|
|
3844
|
+
# Adaptive timeout: 30s with TCP, 0.1s without
|
|
3845
|
+
poll_timeout = 30.0 if notify_server else STOP_HOOK_POLL_INTERVAL
|
|
3846
|
+
|
|
3847
|
+
try:
|
|
3848
|
+
update_instance_position(instance_name, updates)
|
|
3849
|
+
except Exception as e:
|
|
3850
|
+
log_hook_error(f'stop:update_instance_position({instance_name})', e)
|
|
3851
|
+
|
|
3852
|
+
start_time = time.time()
|
|
3853
|
+
|
|
3854
|
+
try:
|
|
3855
|
+
first_poll = True
|
|
3856
|
+
last_heartbeat = start_time
|
|
3857
|
+
# Actual polling loop - this IS the holding pattern
|
|
3858
|
+
while time.time() - start_time < timeout:
|
|
3859
|
+
if first_poll:
|
|
3860
|
+
first_poll = False
|
|
3861
|
+
|
|
3862
|
+
# Reload instance data each poll iteration
|
|
3863
|
+
instance_data = load_instance_position(instance_name)
|
|
3864
|
+
|
|
3865
|
+
# Check flag file FIRST (highest priority coordination signal)
|
|
3866
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
3867
|
+
if flag_file.exists():
|
|
3868
|
+
try:
|
|
3869
|
+
flag_file.unlink()
|
|
3870
|
+
except (FileNotFoundError, PermissionError):
|
|
3871
|
+
# Already deleted or locked, continue anyway
|
|
3872
|
+
pass
|
|
3873
|
+
sys.exit(0)
|
|
3874
|
+
|
|
3875
|
+
# Check if session ended (SessionEnd hook fired) - exit without changing status
|
|
3876
|
+
if instance_data.get('session_ended'):
|
|
3877
|
+
sys.exit(0) # Don't overwrite session_ended status (already set by SessionEnd)
|
|
3878
|
+
|
|
3879
|
+
# Check if user input is pending (timestamp fallback) - exit cleanly if recent input
|
|
3880
|
+
last_user_input = instance_data.get('last_user_input', 0)
|
|
3881
|
+
if time.time() - last_user_input < 0.2:
|
|
3882
|
+
sys.exit(0) # Don't overwrite status - let current status remain
|
|
3883
|
+
|
|
3884
|
+
# Check if disabled - mark exited and stop delivering messages (already stopped delivering messages by being disabled at this point...)
|
|
3885
|
+
if not instance_data.get('enabled', False):
|
|
3886
|
+
set_status(instance_name, 'exited')
|
|
3887
|
+
sys.exit(0)
|
|
3888
|
+
|
|
3889
|
+
# Check for new messages and deliver
|
|
3890
|
+
if messages := get_unread_messages(instance_name, update_position=True):
|
|
3891
|
+
messages_to_show = messages[:MAX_MESSAGES_PER_DELIVERY]
|
|
3892
|
+
reason = format_hook_messages(messages_to_show, instance_name)
|
|
3893
|
+
set_status(instance_name, 'delivered', messages_to_show[0]['from'])
|
|
3894
|
+
|
|
3895
|
+
output = {"decision": "block", "reason": reason}
|
|
3896
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
3897
|
+
sys.exit(2)
|
|
3898
|
+
|
|
3899
|
+
# Wait for notification or timeout
|
|
3900
|
+
if notify_server:
|
|
3901
|
+
try:
|
|
3902
|
+
readable, _, _ = select.select([notify_server], [], [], poll_timeout)
|
|
3903
|
+
# Update heartbeat after select (timeout or wake) to prove loop is alive
|
|
3904
|
+
try:
|
|
3905
|
+
update_instance_position(instance_name, {'last_stop': time.time()})
|
|
3906
|
+
except Exception:
|
|
3907
|
+
pass
|
|
3908
|
+
if readable:
|
|
3909
|
+
# Drain all pending notifications
|
|
3910
|
+
while True:
|
|
3911
|
+
try:
|
|
3912
|
+
conn, _ = notify_server.accept()
|
|
3913
|
+
conn.close()
|
|
3914
|
+
except BlockingIOError:
|
|
3915
|
+
break
|
|
3916
|
+
except (OSError, ValueError, InterruptedError) as e:
|
|
3917
|
+
# Socket became invalid or interrupted - switch to polling
|
|
3918
|
+
log_hook_error(f'stop:select_failed({instance_name})', e)
|
|
3919
|
+
try:
|
|
3920
|
+
notify_server.close()
|
|
3921
|
+
except:
|
|
3922
|
+
pass
|
|
3923
|
+
notify_server = None
|
|
3924
|
+
poll_timeout = STOP_HOOK_POLL_INTERVAL # Fallback to fast polling
|
|
3925
|
+
# Declare fallback mode (self-reporting state)
|
|
3926
|
+
try:
|
|
3927
|
+
update_instance_position(instance_name, {'tcp_mode': False, 'notify_port': None})
|
|
3928
|
+
except Exception:
|
|
3929
|
+
pass
|
|
3930
|
+
else:
|
|
3931
|
+
# Fallback mode - still need heartbeat for staleness detection
|
|
3932
|
+
now = time.time()
|
|
3933
|
+
if now - last_heartbeat >= 0.5:
|
|
3934
|
+
try:
|
|
3935
|
+
update_instance_position(instance_name, {'last_stop': now})
|
|
3936
|
+
last_heartbeat = now
|
|
3937
|
+
except Exception as e:
|
|
3938
|
+
log_hook_error(f'stop:heartbeat_update({instance_name})', e)
|
|
3939
|
+
time.sleep(poll_timeout)
|
|
3940
|
+
|
|
3941
|
+
except Exception as loop_e:
|
|
3942
|
+
# Log polling loop errors but continue to cleanup
|
|
3943
|
+
log_hook_error(f'stop:polling_loop({instance_name})', loop_e)
|
|
3944
|
+
finally:
|
|
3945
|
+
# Cleanup for ALL exit paths (sys.exit, exception, or normal completion)
|
|
3946
|
+
if notify_server:
|
|
3947
|
+
try:
|
|
3948
|
+
notify_server.close()
|
|
3949
|
+
update_instance_position(instance_name, {
|
|
3950
|
+
'notify_port': None,
|
|
3951
|
+
'tcp_mode': False
|
|
3952
|
+
})
|
|
3953
|
+
except Exception:
|
|
3954
|
+
pass # Suppress cleanup errors
|
|
3955
|
+
|
|
3956
|
+
# If we reach here, timeout occurred (all other paths exited in loop)
|
|
3957
|
+
set_status(instance_name, 'exited')
|
|
3958
|
+
sys.exit(0)
|
|
3959
|
+
|
|
3960
|
+
except Exception as e:
|
|
3961
|
+
# Log error and exit gracefully
|
|
3962
|
+
log_hook_error('handle_stop', e)
|
|
3963
|
+
sys.exit(0) # Preserve previous status on exception
|
|
3964
|
+
|
|
3965
|
+
|
|
3966
|
+
def handle_subagent_stop(hook_data: dict[str, Any], parent_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3967
|
+
"""SubagentStop: Guard hook - directs subagents to use 'done' command.
|
|
3968
|
+
Group timeout: if any subagent disabled or idle too long, disable ALL.
|
|
3969
|
+
parent_name is because subagents share the same session_id as parents
|
|
3970
|
+
(so instance_data in this case is the same for parents and children).
|
|
3971
|
+
Parents will never run this hook. Normal instances will never hit this hook.
|
|
3972
|
+
This hook is only for subagents. Only subagent will ever hit this hook.
|
|
3973
|
+
The name will resolve to parents name. This is normal and does not mean that
|
|
3974
|
+
the parent is running this hook. Only subagents run this hook.
|
|
3975
|
+
"""
|
|
3976
|
+
|
|
3977
|
+
# Check subagent states
|
|
3978
|
+
positions = load_all_positions()
|
|
3979
|
+
has_enabled = False
|
|
3980
|
+
has_disabled = False
|
|
3981
|
+
any_subagent_exists = False
|
|
3982
|
+
|
|
3983
|
+
for name, data in positions.items():
|
|
3984
|
+
if name.startswith(f"{parent_name}_") and name != parent_name:
|
|
3985
|
+
any_subagent_exists = True
|
|
3986
|
+
if data.get('enabled', False):
|
|
3987
|
+
has_enabled = True
|
|
3988
|
+
else:
|
|
3989
|
+
has_disabled = True
|
|
3990
|
+
|
|
3991
|
+
# Exit silently if no subagents
|
|
3992
|
+
if not any_subagent_exists:
|
|
3993
|
+
sys.exit(0)
|
|
3994
|
+
|
|
3995
|
+
# If any subagent disabled (timed out in PostToolUse), disable all and exit
|
|
3996
|
+
if has_disabled:
|
|
3997
|
+
for name in positions:
|
|
3998
|
+
if name.startswith(f"{parent_name}_") and name != parent_name:
|
|
3999
|
+
update_instance_position(name, {'enabled': False})
|
|
4000
|
+
set_status(name, 'exited', 'timeout')
|
|
4001
|
+
sys.exit(0)
|
|
4002
|
+
|
|
4003
|
+
# Exit silently if all disabled
|
|
4004
|
+
if not has_enabled:
|
|
4005
|
+
sys.exit(0)
|
|
4006
|
+
|
|
4007
|
+
# Check timeout - if any subagent idle too long, disable all and exit
|
|
4008
|
+
timeout = get_config().subagent_timeout
|
|
4009
|
+
now = time.time()
|
|
4010
|
+
|
|
4011
|
+
for name, data in positions.items():
|
|
4012
|
+
if name.startswith(f"{parent_name}_") and name != parent_name:
|
|
4013
|
+
if data.get('enabled', False):
|
|
4014
|
+
last_stop = data.get('last_stop', 0)
|
|
4015
|
+
if last_stop > 0 and (now - last_stop) > timeout:
|
|
4016
|
+
# Timeout exceeded - disable all subagents
|
|
4017
|
+
for name2 in positions:
|
|
4018
|
+
if name2.startswith(f"{parent_name}_") and name2 != parent_name:
|
|
4019
|
+
update_instance_position(name2, {'enabled': False})
|
|
4020
|
+
set_status(name2, 'exited', 'timeout')
|
|
4021
|
+
sys.exit(0)
|
|
4022
|
+
|
|
4023
|
+
# reminder to run 'done' command
|
|
4024
|
+
reminder = (
|
|
4025
|
+
"[HCOM]: You MUST run 'hcom send done --_hcom_sender <your_alias>' "
|
|
4026
|
+
"This allows you to receive messages and prevents timeout. "
|
|
4027
|
+
"Run this command NOW."
|
|
4028
|
+
)
|
|
4029
|
+
|
|
4030
|
+
output = {"decision": "block", "reason": reminder}
|
|
4031
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
4032
|
+
sys.exit(2)
|
|
4033
|
+
|
|
4034
|
+
|
|
4035
|
+
def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
4036
|
+
"""Handle Notification hook - track permission requests"""
|
|
4037
|
+
message = hook_data.get('message', '')
|
|
4038
|
+
|
|
4039
|
+
# Filter generic message only when instance is already idle (Stop hook running)
|
|
4040
|
+
if message == "Claude is waiting for your input":
|
|
4041
|
+
current_status = instance_data.get('status', '') if instance_data else ''
|
|
4042
|
+
if current_status == 'waiting':
|
|
4043
|
+
return # Instance is idle, Stop hook will maintain waiting status
|
|
4044
|
+
|
|
4045
|
+
# Update status based on subagent context
|
|
4046
|
+
if instance_data and in_subagent_context(instance_data):
|
|
4047
|
+
# In subagent context - update only subagents in current_subagents list (don't update parent)
|
|
4048
|
+
current_list = instance_data.get('current_subagents', [])
|
|
4049
|
+
for subagent_id in current_list:
|
|
4050
|
+
set_status(subagent_id, 'blocked', message)
|
|
4051
|
+
else:
|
|
4052
|
+
# Not in Task (parent context) - update parent only
|
|
4053
|
+
updates['notification_message'] = message
|
|
4054
|
+
update_instance_position(instance_name, updates)
|
|
4055
|
+
set_status(instance_name, 'blocked', message)
|
|
4056
|
+
|
|
4057
|
+
|
|
4058
|
+
def get_user_input_flag_file(instance_name: str) -> Path:
|
|
4059
|
+
"""Get path to user input coordination flag file"""
|
|
4060
|
+
return hcom_path(FLAGS_DIR, f'{instance_name}.user_input')
|
|
4061
|
+
|
|
4062
|
+
def wait_for_stop_exit(instance_name: str, max_wait: float = 0.2) -> int:
|
|
4063
|
+
"""
|
|
4064
|
+
Wait for Stop hook to exit using flag file coordination.
|
|
4065
|
+
Returns wait time in ms.
|
|
4066
|
+
Strategy:
|
|
4067
|
+
1. Create flag file
|
|
4068
|
+
2. Wait for Stop hook to delete it (proof it exited)
|
|
4069
|
+
3. Fallback to timeout if Stop hook doesn't delete flag
|
|
4070
|
+
"""
|
|
4071
|
+
start = time.time()
|
|
4072
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
4073
|
+
|
|
4074
|
+
# Wait for flag file to be deleted by Stop hook
|
|
4075
|
+
while flag_file.exists() and time.time() - start < max_wait:
|
|
4076
|
+
time.sleep(0.01)
|
|
4077
|
+
|
|
4078
|
+
return int((time.time() - start) * 1000)
|
|
4079
|
+
|
|
4080
|
+
def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], is_matched_resume: bool, instance_data: dict[str, Any] | None) -> None:
|
|
4081
|
+
"""Handle UserPromptSubmit hook - track when user sends messages"""
|
|
4082
|
+
is_enabled = instance_data.get('enabled', False) if instance_data else False
|
|
4083
|
+
last_stop = instance_data.get('last_stop', 0) if instance_data else 0
|
|
4084
|
+
alias_announced = instance_data.get('alias_announced', False) if instance_data else False
|
|
4085
|
+
notify_port = instance_data.get('notify_port') if instance_data else None
|
|
4086
|
+
|
|
4087
|
+
# Session_ended prevents user receiving messages(?) so reset it.
|
|
4088
|
+
if is_matched_resume and instance_data and instance_data.get('session_ended'):
|
|
4089
|
+
update_instance_position(instance_name, {'session_ended': False})
|
|
4090
|
+
instance_data['session_ended'] = False # Resume path reactivates Stop hook polling
|
|
4091
|
+
|
|
4092
|
+
# Disable orphaned subagents (user cancelled/interrupted Task or resumed)
|
|
4093
|
+
if instance_data:
|
|
4094
|
+
positions = load_all_positions()
|
|
4095
|
+
for name, pos_data in positions.items():
|
|
4096
|
+
if name.startswith(f"{instance_name}_"):
|
|
4097
|
+
disable_instance(name)
|
|
4098
|
+
# Only set exited if not already exited
|
|
4099
|
+
current_status = pos_data.get('status', 'unknown')
|
|
4100
|
+
if current_status != 'exited':
|
|
4101
|
+
set_status(name, 'exited', 'orphaned')
|
|
4102
|
+
# Clear current subagents list if set
|
|
4103
|
+
if (instance_data.get('current_subagents')):
|
|
4104
|
+
update_instance_position(instance_name, {'current_subagents': []})
|
|
4105
|
+
|
|
4106
|
+
# Coordinate with Stop hook only if enabled AND Stop hook is active
|
|
4107
|
+
# Determine if stop hook is active - check tcp_mode or timestamp
|
|
4108
|
+
tcp_mode = instance_data.get('tcp_mode', False) if instance_data else False
|
|
4109
|
+
if tcp_mode:
|
|
4110
|
+
# TCP mode - assume active (stop hook self-reports if it exits/fails)
|
|
4111
|
+
stop_is_active = True
|
|
4112
|
+
else:
|
|
4113
|
+
# Fallback mode - check timestamp
|
|
4114
|
+
stop_is_active = (time.time() - last_stop) < 1.0
|
|
4115
|
+
|
|
4116
|
+
if is_enabled and stop_is_active:
|
|
4117
|
+
# Create flag file FIRST (must exist before Stop hook wakes)
|
|
4118
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
4119
|
+
try:
|
|
4120
|
+
flag_file.touch()
|
|
4121
|
+
except (OSError, PermissionError):
|
|
4122
|
+
# Failed to create flag, fall back to timestamp-only coordination
|
|
4123
|
+
pass
|
|
4124
|
+
|
|
4125
|
+
# Set timestamp (backup mechanism)
|
|
4126
|
+
updates['last_user_input'] = time.time()
|
|
4127
|
+
update_instance_position(instance_name, updates)
|
|
4128
|
+
|
|
4129
|
+
# Send TCP notification LAST (Stop hook wakes, sees flag, exits immediately)
|
|
4130
|
+
if notify_port:
|
|
4131
|
+
notify_instance(instance_name)
|
|
4132
|
+
|
|
4133
|
+
# Wait for Stop hook to delete flag file (PROOF of exit)
|
|
4134
|
+
wait_for_stop_exit(instance_name)
|
|
4135
|
+
|
|
4136
|
+
# Build message based on what happened
|
|
4137
|
+
msg = None
|
|
4138
|
+
|
|
4139
|
+
# Determine if this is an HCOM-launched instance
|
|
4140
|
+
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
4141
|
+
|
|
4142
|
+
# Show bootstrap if not already announced
|
|
4143
|
+
if not alias_announced:
|
|
4144
|
+
if is_hcom_launched:
|
|
4145
|
+
# HCOM-launched instance - show bootstrap immediately
|
|
4146
|
+
msg = build_hcom_bootstrap_text(instance_name)
|
|
4147
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
4148
|
+
else:
|
|
4149
|
+
# Vanilla Claude instance - check if user is about to run an hcom command
|
|
4150
|
+
user_prompt = hook_data.get('prompt', '')
|
|
4151
|
+
hcom_command_pattern = r'\bhcom\s+\w+'
|
|
4152
|
+
if re.search(hcom_command_pattern, user_prompt, re.IGNORECASE):
|
|
4153
|
+
# Bootstrap not shown yet - show it preemptively before hcom command runs
|
|
4154
|
+
msg = "[HCOM COMMAND DETECTED]\n\n"
|
|
4155
|
+
msg += build_hcom_bootstrap_text(instance_name)
|
|
4156
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
4157
|
+
|
|
4158
|
+
# Add resume status note if we showed bootstrap for a matched resume
|
|
4159
|
+
if msg and is_matched_resume:
|
|
4160
|
+
if is_enabled:
|
|
4161
|
+
msg += "\n[HCOM Session resumed. Your alias and conversation history preserved.]"
|
|
4162
|
+
if msg:
|
|
4163
|
+
output = {
|
|
4164
|
+
"hookSpecificOutput": {
|
|
4165
|
+
"hookEventName": "UserPromptSubmit",
|
|
4166
|
+
"additionalContext": msg
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
print(json.dumps(output), file=sys.stdout)
|
|
4170
|
+
|
|
4171
|
+
def handle_sessionstart(hook_data: dict[str, Any]) -> None:
|
|
4172
|
+
"""Handle SessionStart hook - initial msg & reads environment variables"""
|
|
4173
|
+
# Only show message for HCOM-launched instances
|
|
4174
|
+
if os.environ.get('HCOM_LAUNCHED') == '1':
|
|
4175
|
+
parts = f"[HCOM is started, you can send messages with the command: {build_hcom_command()} send]"
|
|
4176
|
+
else:
|
|
4177
|
+
parts = f"[You can start HCOM with the command: {build_hcom_command()} start]"
|
|
4178
|
+
|
|
4179
|
+
output = {
|
|
4180
|
+
"hookSpecificOutput": {
|
|
4181
|
+
"hookEventName": "SessionStart",
|
|
4182
|
+
"additionalContext": parts
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
print(json.dumps(output))
|
|
4187
|
+
|
|
4188
|
+
def handle_posttooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
4189
|
+
"""Handle PostToolUse hook - show launch context or bootstrap"""
|
|
4190
|
+
tool_name = hook_data.get('tool_name', '')
|
|
4191
|
+
tool_input = hook_data.get('tool_input', {})
|
|
4192
|
+
tool_response = hook_data.get('tool_response', {})
|
|
4193
|
+
instance_data = load_instance_position(instance_name)
|
|
4194
|
+
|
|
4195
|
+
# Deliver freeze-period message history when Task tool completes (parent context only)
|
|
4196
|
+
if tool_name == 'Task':
|
|
4197
|
+
parent_pos = instance_data.get('pos', 0) if instance_data else 0
|
|
4198
|
+
|
|
4199
|
+
# Get ALL messages from freeze period first (to get correct end position)
|
|
4200
|
+
result = parse_log_messages(hcom_path('hcom.log'), start_pos=parent_pos)
|
|
4201
|
+
all_messages = result.messages
|
|
4202
|
+
new_pos = result.end_position # Correct end position from full parse
|
|
4203
|
+
|
|
4204
|
+
# Get subagent activity (messages FROM/TO subagents)
|
|
4205
|
+
subagent_msgs, _, _ = get_subagent_messages(instance_name, since_pos=parent_pos)
|
|
4206
|
+
|
|
4207
|
+
# Get messages that parent would have received (broadcasts, @mentions to parent)
|
|
4208
|
+
positions = load_all_positions()
|
|
4209
|
+
all_instance_names = list(positions.keys())
|
|
4210
|
+
|
|
4211
|
+
parent_msgs = []
|
|
4212
|
+
for msg in all_messages:
|
|
4213
|
+
# Skip if already in subagent messages
|
|
4214
|
+
if msg in subagent_msgs:
|
|
4215
|
+
continue
|
|
4216
|
+
# Get parent's normal messages (broadcasts, @mentions) that arrived during freeze
|
|
4217
|
+
if should_deliver_message(msg, instance_name, all_instance_names):
|
|
4218
|
+
parent_msgs.append(msg)
|
|
4219
|
+
|
|
4220
|
+
# Combine and sort by timestamp
|
|
4221
|
+
all_relevant = subagent_msgs + parent_msgs
|
|
4222
|
+
all_relevant.sort(key=lambda m: m['timestamp'])
|
|
4223
|
+
|
|
4224
|
+
if all_relevant:
|
|
4225
|
+
# Format as conversation log with clear temporal framing
|
|
4226
|
+
msg_lines = []
|
|
4227
|
+
for msg in all_relevant:
|
|
4228
|
+
msg_lines.append(f"{msg['from']}: {msg['message']}")
|
|
4229
|
+
|
|
4230
|
+
formatted = '\n'.join(msg_lines)
|
|
4231
|
+
summary = (
|
|
4232
|
+
f"[Task tool completed - Message history during Task tool]\n"
|
|
4233
|
+
f"The following {len(all_relevant)} message(s) occurred between Task tool start and completion:\n\n"
|
|
4234
|
+
f"{formatted}\n\n"
|
|
4235
|
+
f"[End of message history. These subagent(s) have finished their Task and are no longer active.]"
|
|
4236
|
+
)
|
|
4237
|
+
|
|
4238
|
+
output = {
|
|
4239
|
+
"hookSpecificOutput": {
|
|
4240
|
+
"hookEventName": "PostToolUse",
|
|
4241
|
+
"additionalContext": summary
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
4245
|
+
update_instance_position(instance_name, {'pos': new_pos, 'current_subagents': []})
|
|
4246
|
+
else:
|
|
4247
|
+
# No relevant messages, just clear current subagents and advance position
|
|
4248
|
+
update_instance_position(instance_name, {'pos': new_pos, 'current_subagents': []})
|
|
4249
|
+
|
|
4250
|
+
# Extract and save agentId mapping
|
|
4251
|
+
agent_id = tool_response.get('agentId')
|
|
4252
|
+
if agent_id:
|
|
4253
|
+
# Extract HCOM subagent_id from injected prompt
|
|
4254
|
+
prompt = tool_input.get('prompt', '')
|
|
4255
|
+
match = re.search(r'--_hcom_sender\s+(\S+)', prompt)
|
|
4256
|
+
if match:
|
|
4257
|
+
hcom_subagent_id = match.group(1)
|
|
4258
|
+
|
|
4259
|
+
# Store bidirectional mapping in parent instance
|
|
4260
|
+
mappings = instance_data.get('subagent_mappings', {}) if instance_data else {}
|
|
4261
|
+
mappings[hcom_subagent_id] = agent_id
|
|
4262
|
+
update_instance_position(instance_name, {'subagent_mappings': mappings})
|
|
4263
|
+
|
|
4264
|
+
# Mark all subagents exited (Task completed, confirmed all exited)
|
|
4265
|
+
positions = load_all_positions()
|
|
4266
|
+
for name in positions:
|
|
4267
|
+
if name.startswith(f"{instance_name}_"):
|
|
4268
|
+
set_status(name, 'exited', 'task_completed')
|
|
4269
|
+
sys.exit(0)
|
|
4270
|
+
|
|
4271
|
+
# Bash-specific logic: check for hcom commands and show context/bootstrap
|
|
4272
|
+
if tool_name == 'Bash':
|
|
4273
|
+
command = hook_data.get('tool_input', {}).get('command', '')
|
|
4274
|
+
# Detect subagent context
|
|
4275
|
+
if instance_data and in_subagent_context(instance_data):
|
|
4276
|
+
# Inject instructions when subagent runs 'hcom start'
|
|
4277
|
+
if '--_hcom_sender' in command and re.search(r'\bhcom\s+start\b', command):
|
|
4278
|
+
match = re.search(r'--_hcom_sender\s+(\S+)', command)
|
|
4279
|
+
if match:
|
|
4280
|
+
subagent_alias = match.group(1)
|
|
4281
|
+
msg = format_subagent_hcom_instructions(subagent_alias)
|
|
4282
|
+
output = {
|
|
4283
|
+
"hookSpecificOutput": {
|
|
4284
|
+
"hookEventName": "PostToolUse",
|
|
4285
|
+
"additionalContext": msg
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
4289
|
+
sys.exit(0)
|
|
4290
|
+
|
|
4291
|
+
# Detect subagent 'done' command - subagent finished work, waiting for messages
|
|
4292
|
+
if 'done' in command and '--_hcom_sender' in command:
|
|
4293
|
+
match = re.search(r'--_hcom_sender\s+(\S+)', command)
|
|
4294
|
+
if match:
|
|
4295
|
+
subagent_id = match.group(1)
|
|
4296
|
+
# Check if disabled - exit immediately
|
|
4297
|
+
instance_data_sub = load_instance_position(subagent_id)
|
|
4298
|
+
if not instance_data_sub or not instance_data_sub.get('enabled', False):
|
|
4299
|
+
sys.exit(0)
|
|
4300
|
+
|
|
4301
|
+
# Update heartbeat and status to mark as waiting
|
|
4302
|
+
update_instance_position(subagent_id, {'last_stop': time.time()})
|
|
4303
|
+
set_status(subagent_id, 'waiting')
|
|
4304
|
+
|
|
4305
|
+
# Run polling loop with KNOWN identity
|
|
4306
|
+
timeout = get_config().subagent_timeout
|
|
4307
|
+
start = time.time()
|
|
4308
|
+
|
|
4309
|
+
while time.time() - start < timeout:
|
|
4310
|
+
# Check disabled on each iteration
|
|
4311
|
+
instance_data_sub = load_instance_position(subagent_id)
|
|
4312
|
+
if not instance_data_sub or not instance_data_sub.get('enabled', False):
|
|
4313
|
+
sys.exit(0)
|
|
4314
|
+
|
|
4315
|
+
messages = get_unread_messages(subagent_id, update_position=False)
|
|
4316
|
+
|
|
4317
|
+
if messages:
|
|
4318
|
+
# Targeted delivery to THIS subagent only
|
|
4319
|
+
formatted = format_hook_messages(messages, subagent_id)
|
|
4320
|
+
# Mark as read and set delivery status
|
|
4321
|
+
result = parse_log_messages(hcom_path(LOG_FILE),
|
|
4322
|
+
instance_data_sub.get('pos', 0))
|
|
4323
|
+
update_instance_position(subagent_id, {'pos': result.end_position})
|
|
4324
|
+
set_status(subagent_id, 'delivered', messages[-1]['from'])
|
|
4325
|
+
# Use additionalContext only (no decision:block)
|
|
4326
|
+
output = {
|
|
4327
|
+
"hookSpecificOutput": {
|
|
4328
|
+
"hookEventName": "PostToolUse",
|
|
4329
|
+
"additionalContext": formatted
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
4333
|
+
sys.exit(0)
|
|
4334
|
+
|
|
4335
|
+
# Update heartbeat during wait
|
|
4336
|
+
update_instance_position(subagent_id, {'last_stop': time.time()})
|
|
4337
|
+
time.sleep(1)
|
|
4338
|
+
|
|
4339
|
+
# Timeout - no messages, disable this subagent
|
|
4340
|
+
update_instance_position(subagent_id, {'enabled': False})
|
|
4341
|
+
set_status(subagent_id, 'waiting', 'timeout')
|
|
4342
|
+
sys.exit(0)
|
|
4343
|
+
|
|
4344
|
+
# All exits above handled - this code unreachable but keep for consistency
|
|
4345
|
+
sys.exit(0)
|
|
4346
|
+
|
|
4347
|
+
# Parent context - show launch context and bootstrap
|
|
4348
|
+
|
|
4349
|
+
# Check for help or launch commands (combined pattern)
|
|
4350
|
+
if re.search(r'\bhcom\s+(?:(?:help|--help|-h)\b|\d+)', command):
|
|
4351
|
+
if not instance_data.get('launch_context_announced', False):
|
|
4352
|
+
msg = build_launch_context(instance_name)
|
|
4353
|
+
update_instance_position(instance_name, {'launch_context_announced': True})
|
|
4354
|
+
|
|
4355
|
+
output = {
|
|
4356
|
+
"hookSpecificOutput": {
|
|
4357
|
+
"hookEventName": "PostToolUse",
|
|
4358
|
+
"additionalContext": msg
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
4362
|
+
sys.exit(0)
|
|
4363
|
+
|
|
4364
|
+
# Check HCOM_COMMAND_PATTERN for bootstrap (other hcom commands)
|
|
4365
|
+
matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
|
|
4366
|
+
|
|
4367
|
+
if not matches:
|
|
4368
|
+
sys.exit(0)
|
|
4369
|
+
|
|
4370
|
+
# Check for external stop notification
|
|
4371
|
+
# Detect subagent context for stop notification
|
|
4372
|
+
check_name = instance_name
|
|
4373
|
+
check_data = instance_data
|
|
4374
|
+
if '--_hcom_sender' in command:
|
|
4375
|
+
match = re.search(r'--_hcom_sender\s+(\S+)', command)
|
|
4376
|
+
if match:
|
|
4377
|
+
check_name = match.group(1)
|
|
4378
|
+
check_data = load_instance_position(check_name)
|
|
4379
|
+
|
|
4380
|
+
if check_data and check_data.get('external_stop_pending'):
|
|
4381
|
+
# Clear flag immediately so it only shows once
|
|
4382
|
+
update_instance_position(check_name, {'external_stop_pending': False})
|
|
4383
|
+
|
|
4384
|
+
# Only show if disabled AND was previously enabled (within the window)
|
|
4385
|
+
if not check_data.get('enabled', False) and check_data.get('previously_enabled', False):
|
|
4386
|
+
message = (
|
|
4387
|
+
"[HCOM NOTIFICATION]\n"
|
|
4388
|
+
"Your HCOM connection has been stopped by an external command.\n"
|
|
4389
|
+
"You will no longer receive messages automatically. Stop your current work unless instructed otherwise."
|
|
4390
|
+
)
|
|
4391
|
+
output = {
|
|
4392
|
+
"hookSpecificOutput": {
|
|
4393
|
+
"hookEventName": "PostToolUse",
|
|
4394
|
+
"additionalContext": message
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
4398
|
+
sys.exit(0)
|
|
4399
|
+
|
|
4400
|
+
# Show bootstrap if not announced yet
|
|
4401
|
+
if not instance_data.get('alias_announced', False):
|
|
4402
|
+
msg = build_hcom_bootstrap_text(instance_name)
|
|
4403
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
4404
|
+
|
|
4405
|
+
output = {
|
|
4406
|
+
"hookSpecificOutput": {
|
|
4407
|
+
"hookEventName": "PostToolUse",
|
|
4408
|
+
"additionalContext": msg
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
4412
|
+
sys.exit(0)
|
|
4413
|
+
|
|
4414
|
+
def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
4415
|
+
"""Handle SessionEnd hook - mark session as ended and set final status"""
|
|
4416
|
+
reason = hook_data.get('reason', 'unknown')
|
|
4417
|
+
|
|
4418
|
+
# Set session_ended flag to tell Stop hook to exit
|
|
4419
|
+
updates['session_ended'] = True
|
|
4420
|
+
|
|
4421
|
+
# Set status to exited with reason as context (reason: clear, logout, prompt_input_exit, other)
|
|
4422
|
+
set_status(instance_name, 'exited', reason)
|
|
4423
|
+
|
|
4424
|
+
try:
|
|
4425
|
+
update_instance_position(instance_name, updates)
|
|
4426
|
+
except Exception as e:
|
|
4427
|
+
log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
|
|
4428
|
+
|
|
4429
|
+
# Notify instance to wake and exit cleanly
|
|
4430
|
+
notify_instance(instance_name)
|
|
4431
|
+
|
|
4432
|
+
def should_skip_vanilla_instance(hook_type: str, hook_data: dict) -> bool:
|
|
4433
|
+
"""
|
|
4434
|
+
Returns True if hook should exit early.
|
|
4435
|
+
Vanilla instances (not HCOM-launched) exit early unless:
|
|
4436
|
+
- Enabled
|
|
4437
|
+
- PreToolUse (handles opt-in)
|
|
4438
|
+
- UserPromptSubmit with hcom command in prompt (shows preemptive bootstrap)
|
|
4439
|
+
"""
|
|
4440
|
+
# PreToolUse always runs (handles toggle commands)
|
|
4441
|
+
# HCOM-launched instances always run
|
|
4442
|
+
if hook_type == 'pre' or os.environ.get('HCOM_LAUNCHED') == '1':
|
|
4443
|
+
return False
|
|
4444
|
+
|
|
4445
|
+
session_id = hook_data.get('session_id', '')
|
|
4446
|
+
if not session_id: # No session_id = can't identify instance, skip hook
|
|
4447
|
+
return True
|
|
4448
|
+
|
|
4449
|
+
instance_name = get_display_name(session_id, get_config().tag)
|
|
4450
|
+
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
4451
|
+
|
|
4452
|
+
if not instance_file.exists():
|
|
4453
|
+
# Allow UserPromptSubmit if prompt contains hcom command
|
|
4454
|
+
if hook_type == 'userpromptsubmit':
|
|
4455
|
+
user_prompt = hook_data.get('prompt', '')
|
|
4456
|
+
return not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE)
|
|
4457
|
+
return True
|
|
4458
|
+
|
|
4459
|
+
return False
|
|
4460
|
+
|
|
4461
|
+
def handle_hook(hook_type: str) -> None:
|
|
4462
|
+
"""Unified hook handler for all HCOM hooks"""
|
|
4463
|
+
hook_data = json.load(sys.stdin)
|
|
4464
|
+
|
|
4465
|
+
if not ensure_hcom_directories():
|
|
4466
|
+
log_hook_error('handle_hook', Exception('Failed to create directories'))
|
|
4467
|
+
sys.exit(0)
|
|
4468
|
+
|
|
4469
|
+
# SessionStart is standalone - no instance files
|
|
4470
|
+
if hook_type == 'sessionstart':
|
|
4471
|
+
handle_sessionstart(hook_data)
|
|
4472
|
+
sys.exit(0)
|
|
4473
|
+
|
|
4474
|
+
# Vanilla instance check - exit early if should skip
|
|
4475
|
+
if should_skip_vanilla_instance(hook_type, hook_data):
|
|
4476
|
+
sys.exit(0)
|
|
4477
|
+
|
|
4478
|
+
# Initialize instance context (creates file if needed, reuses existing if session_id matches)
|
|
4479
|
+
instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
|
|
4480
|
+
|
|
4481
|
+
# Load instance data once (for enabled check and to pass to handlers)
|
|
4482
|
+
instance_data = None
|
|
4483
|
+
if hook_type != 'pre':
|
|
4484
|
+
instance_data = load_instance_position(instance_name)
|
|
4485
|
+
|
|
4486
|
+
# Clean up current_subagents if set (regardless of enabled state)
|
|
4487
|
+
# Skip for PostToolUse - let handle_posttooluse deliver messages first, then cleanup
|
|
4488
|
+
# Only run for parent instances (subagents don't manage current_subagents)
|
|
4489
|
+
# NOW: LET HOOKS HANDLE THIS
|
|
4490
|
+
|
|
4491
|
+
# Skip enabled check for UserPromptSubmit when bootstrap needs to be shown
|
|
4492
|
+
# (alias_announced=false means bootstrap hasn't been shown yet)
|
|
4493
|
+
# Skip enabled check for PostToolUse in subagent context (need to deliver subagent messages)
|
|
4494
|
+
# Skip enabled check for SubagentStop (resolves to parent name, but runs for subagents)
|
|
4495
|
+
skip_enabled_check = (
|
|
4496
|
+
(hook_type == 'userpromptsubmit' and not instance_data.get('alias_announced', False)) or
|
|
4497
|
+
(hook_type == 'post' and in_subagent_context(instance_data)) or
|
|
4498
|
+
(hook_type == 'subagent-stop')
|
|
4499
|
+
)
|
|
4500
|
+
|
|
4501
|
+
if not skip_enabled_check:
|
|
4502
|
+
# Skip vanilla instances (never participated)
|
|
4503
|
+
if not instance_data.get('previously_enabled', False):
|
|
4504
|
+
sys.exit(0)
|
|
4505
|
+
|
|
4506
|
+
# Skip exited instances - frozen until restart
|
|
4507
|
+
status = instance_data.get('status')
|
|
4508
|
+
if status == 'exited':
|
|
4509
|
+
# Exception: Allow Stop hook to run when re-enabled (transitions back to 'waiting')
|
|
4510
|
+
if not (hook_type == 'poll' and instance_data.get('enabled', False)):
|
|
4511
|
+
sys.exit(0)
|
|
4512
|
+
|
|
4513
|
+
match hook_type:
|
|
4514
|
+
case 'pre':
|
|
4515
|
+
handle_pretooluse(hook_data, instance_name)
|
|
4516
|
+
case 'post':
|
|
4517
|
+
handle_posttooluse(hook_data, instance_name)
|
|
4518
|
+
case 'poll':
|
|
4519
|
+
handle_stop(hook_data, instance_name, updates, instance_data)
|
|
4520
|
+
case 'subagent-stop':
|
|
4521
|
+
handle_subagent_stop(hook_data, instance_name, updates, instance_data)
|
|
4522
|
+
case 'notify':
|
|
4523
|
+
handle_notify(hook_data, instance_name, updates, instance_data)
|
|
4524
|
+
case 'userpromptsubmit':
|
|
4525
|
+
handle_userpromptsubmit(hook_data, instance_name, updates, is_matched_resume, instance_data)
|
|
4526
|
+
case 'sessionend':
|
|
4527
|
+
handle_sessionend(hook_data, instance_name, updates, instance_data)
|
|
4528
|
+
|
|
4529
|
+
sys.exit(0)
|
|
4530
|
+
|
|
4531
|
+
|
|
4532
|
+
# ==================== Main Entry Point ====================
|
|
4533
|
+
|
|
4534
|
+
def main(argv: list[str] | None = None) -> int | None:
|
|
4535
|
+
"""Main command dispatcher"""
|
|
4536
|
+
if argv is None:
|
|
4537
|
+
argv = sys.argv[1:]
|
|
4538
|
+
else:
|
|
4539
|
+
argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
|
|
4540
|
+
|
|
4541
|
+
# Hook handlers only (called BY hooks, not users)
|
|
4542
|
+
if argv and argv[0] in ('poll', 'notify', 'pre', 'post', 'sessionstart', 'userpromptsubmit', 'sessionend', 'subagent-stop'):
|
|
4543
|
+
handle_hook(argv[0])
|
|
4544
|
+
return 0
|
|
4545
|
+
|
|
4546
|
+
# Ensure directories exist first (required for version check cache)
|
|
4547
|
+
if not ensure_hcom_directories():
|
|
4548
|
+
print(format_error("Failed to create HCOM directories"), file=sys.stderr)
|
|
4549
|
+
return 1
|
|
4550
|
+
|
|
4551
|
+
# Check for updates and show message if available (once daily check, persists until upgrade)
|
|
4552
|
+
if msg := get_update_notice():
|
|
4553
|
+
print(msg, file=sys.stderr)
|
|
4554
|
+
|
|
4555
|
+
# Ensure hooks current (warns but never blocks)
|
|
4556
|
+
ensure_hooks_current()
|
|
4557
|
+
|
|
4558
|
+
# Route to commands
|
|
4559
|
+
try:
|
|
4560
|
+
if not argv:
|
|
4561
|
+
return cmd_watch([])
|
|
4562
|
+
elif argv[0] in ('help', '--help', '-h'):
|
|
4563
|
+
return cmd_help()
|
|
4564
|
+
elif argv[0] in ('--version', '-v'):
|
|
4565
|
+
print(f"hcom {__version__}")
|
|
4566
|
+
return 0
|
|
4567
|
+
elif argv[0] == 'send_cli':
|
|
4568
|
+
if len(argv) < 2:
|
|
4569
|
+
print(format_error("Message required"), file=sys.stderr)
|
|
4570
|
+
return 1
|
|
4571
|
+
return send_cli(argv[1])
|
|
4572
|
+
elif argv[0] == 'watch':
|
|
4573
|
+
return cmd_watch(argv[1:])
|
|
4574
|
+
elif argv[0] == 'send':
|
|
4575
|
+
return cmd_send(argv[1:])
|
|
4576
|
+
elif argv[0] == 'stop':
|
|
4577
|
+
return cmd_stop(argv[1:])
|
|
4578
|
+
elif argv[0] == 'start':
|
|
4579
|
+
return cmd_start(argv[1:])
|
|
4580
|
+
elif argv[0] == 'reset':
|
|
4581
|
+
return cmd_reset(argv[1:])
|
|
4582
|
+
elif argv[0].isdigit() or argv[0] == 'claude':
|
|
4583
|
+
# Launch instances: hcom <1-100> [args] or hcom claude [args]
|
|
4584
|
+
return cmd_launch(argv)
|
|
4585
|
+
else:
|
|
4586
|
+
print(format_error(
|
|
4587
|
+
f"Unknown command: {argv[0]}",
|
|
4588
|
+
"Run 'hcom --help' for usage"
|
|
4589
|
+
), file=sys.stderr)
|
|
4590
|
+
return 1
|
|
4591
|
+
except (CLIError, ValueError) as exc:
|
|
4592
|
+
print(str(exc), file=sys.stderr)
|
|
4593
|
+
return 1
|
|
4594
|
+
|
|
4595
|
+
# ==================== Exports for UI Module ====================
|
|
4596
|
+
|
|
4597
|
+
__all__ = [
|
|
4598
|
+
# Core functions
|
|
4599
|
+
'cmd_launch', 'cmd_start', 'cmd_stop', 'cmd_reset', 'send_message',
|
|
4600
|
+
'get_instance_status', 'parse_log_messages', 'ensure_hcom_directories',
|
|
4601
|
+
'format_age', 'list_available_agents', 'get_status_counts',
|
|
4602
|
+
# Path utilities
|
|
4603
|
+
'hcom_path',
|
|
4604
|
+
# Configuration
|
|
4605
|
+
'get_config', 'reload_config', '_parse_env_value',
|
|
4606
|
+
# Instance operations
|
|
4607
|
+
'load_all_positions', 'should_show_in_watch',
|
|
4608
|
+
# Constants
|
|
4609
|
+
'SENDER', 'SENDER_EMOJI', 'LOG_FILE', 'INSTANCES_DIR',
|
|
4610
|
+
]
|
|
4611
|
+
|
|
4612
|
+
if __name__ == '__main__':
|
|
4613
|
+
sys.exit(main())
|