hcom 0.4.2.post3__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hcom might be problematic. Click here for more details.

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