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