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