hcom 0.3.1__py3-none-any.whl → 0.4.2.post3__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/__main__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- hcom 0.3.1
3
+ hcom
4
4
  CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
5
5
  """
6
6
 
@@ -19,18 +19,20 @@ import platform
19
19
  import random
20
20
  from pathlib import Path
21
21
  from datetime import datetime, timedelta
22
- from typing import Optional, Any, NamedTuple
23
- from dataclasses import dataclass, asdict, field
22
+ from typing import Any, NamedTuple
23
+ from dataclasses import dataclass
24
24
 
25
25
  if sys.version_info < (3, 10):
26
26
  sys.exit("Error: hcom requires Python 3.10 or higher")
27
27
 
28
+ __version__ = "0.5.0"
29
+
28
30
  # ==================== Constants ====================
29
31
 
30
32
  IS_WINDOWS = sys.platform == 'win32'
31
33
 
32
- def is_wsl():
33
- """Detect if running in WSL (Windows Subsystem for Linux)"""
34
+ def is_wsl() -> bool:
35
+ """Detect if running in WSL"""
34
36
  if platform.system() != 'Linux':
35
37
  return False
36
38
  try:
@@ -39,7 +41,7 @@ def is_wsl():
39
41
  except (FileNotFoundError, PermissionError, OSError):
40
42
  return False
41
43
 
42
- def is_termux():
44
+ def is_termux() -> bool:
43
45
  """Detect if running in Termux on Android"""
44
46
  return (
45
47
  'TERMUX_VERSION' in os.environ or # Primary: Works all versions
@@ -48,51 +50,15 @@ def is_termux():
48
50
  'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
49
51
  )
50
52
 
51
- HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
52
- HCOM_ACTIVE_VALUE = '1'
53
-
54
53
  EXIT_SUCCESS = 0
55
54
  EXIT_BLOCK = 2
56
55
 
57
- ERROR_ACCESS_DENIED = 5 # Windows - Process exists but no permission
58
- ERROR_INVALID_PARAMETER = 87 # Windows - Invalid PID or parameters
59
-
60
56
  # Windows API constants
61
57
  CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
62
- PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 # Vista+ minimal access rights
63
- PROCESS_QUERY_INFORMATION = 0x0400 # Pre-Vista process access rights TODO: is this a joke? why am i supporting pre vista? who the fuck is running claude code on vista let alone pre?! great to keep this comment here! and i will be leaving it here!
64
58
 
65
59
  # Timing constants
66
60
  FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
67
61
  STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
68
- KILL_CHECK_INTERVAL = 0.1 # 100ms between process termination checks
69
- MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
70
-
71
- # Windows kernel32 cache
72
- _windows_kernel32_cache = None
73
-
74
- def get_windows_kernel32():
75
- """Get cached Windows kernel32 with function signatures configured.
76
- This eliminates repeated initialization in hot code paths (e.g., stop hook polling).
77
- """
78
- global _windows_kernel32_cache
79
- if _windows_kernel32_cache is None and IS_WINDOWS:
80
- import ctypes
81
- import ctypes.wintypes
82
- kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
83
-
84
- # Set proper ctypes function signatures to avoid ERROR_INVALID_PARAMETER
85
- kernel32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
86
- kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
87
- kernel32.GetLastError.argtypes = []
88
- kernel32.GetLastError.restype = ctypes.wintypes.DWORD
89
- kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
90
- kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
91
- kernel32.GetExitCodeProcess.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(ctypes.wintypes.DWORD)]
92
- kernel32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL
93
-
94
- _windows_kernel32_cache = kernel32
95
- return _windows_kernel32_cache
96
62
 
97
63
  MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
98
64
  AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
@@ -127,9 +93,11 @@ STATUS_INFO = {
127
93
  'tool_pending': ('active', '{} executing'),
128
94
  'waiting': ('waiting', 'idle'),
129
95
  'message_delivered': ('delivered', 'msg from {}'),
130
- 'stop_exit': ('inactive', 'stopped'),
131
96
  'timeout': ('inactive', 'timeout'),
132
- 'killed': ('inactive', 'killed'),
97
+ 'stopped': ('inactive', 'stopped'),
98
+ 'force_stopped': ('inactive', 'force stopped'),
99
+ 'started': ('active', 'starting'),
100
+ 'session_ended': ('inactive', 'ended: {}'),
133
101
  'blocked': ('blocked', '{} blocked'),
134
102
  'unknown': ('unknown', 'unknown'),
135
103
  }
@@ -147,15 +115,70 @@ if IS_WINDOWS or is_wsl():
147
115
  # ==================== Error Handling Strategy ====================
148
116
  # Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
149
117
  # CLI: Can raise exceptions for user feedback. Check return values.
150
- # Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
118
+ # Critical I/O: atomic_write, save_instance_position
151
119
  # Pattern: Try/except/return False in hooks, raise in CLI operations.
152
120
 
153
- def log_hook_error(hook_name: str, error: Exception | None = None):
121
+ # ==================== CLI Errors ====================
122
+
123
+ class CLIError(Exception):
124
+ """Raised when arguments cannot be mapped to command semantics."""
125
+
126
+ # ==================== Help Text ====================
127
+
128
+ HELP_TEXT = """hcom - Claude Hook Comms
129
+
130
+ Usage: [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
131
+ hcom watch [--logs|--status|--wait [SEC]]
132
+ hcom send "message"
133
+ hcom stop [alias|all] [--force]
134
+ hcom start [alias]
135
+ hcom reset [logs|hooks|config]
136
+
137
+ Launch Examples:
138
+ hcom 3 Open 3 terminals with claude connected to hcom
139
+ hcom 3 claude -p + Background/headless
140
+ HCOM_TAG=api hcom 3 claude -p + @-mention group tag
141
+
142
+ Commands:
143
+ watch Interactive messaging/status dashboard
144
+ --logs Print all messages
145
+ --status Print instance status JSON
146
+ --wait [SEC] Wait and notify for new message
147
+
148
+ send "msg" Send message to all instances
149
+ send "@alias msg" Send to specific instance/group
150
+
151
+ stop Stop current instance (from inside Claude)
152
+ stop <alias> Stop specific instance
153
+ stop all Stop all instances
154
+ --force Emergency stop (denies Bash tool)
155
+
156
+ start Start current instance (from inside Claude)
157
+ start <alias> Start specific instance
158
+
159
+ reset Stop all + archive logs + remove hooks + clear config
160
+ reset logs Clear + archive conversation log
161
+ reset hooks Safely remove hcom hooks from claude settings.json
162
+ reset config Clear + backup config.env
163
+
164
+ Environment Variables:
165
+ HCOM_TAG=name Group tag (creates name-* instances)
166
+ HCOM_AGENT=type Agent type (comma-separated for multiple)
167
+ HCOM_TERMINAL=mode Terminal: new|here|print|"custom {script}"
168
+ HCOM_PROMPT=text Initial prompt
169
+ HCOM_TIMEOUT=secs Timeout in seconds (default: 1800)
170
+
171
+ Config: ~/.hcom/config.env
172
+ Docs: https://github.com/aannoo/claude-hook-comms"""
173
+
174
+
175
+ # ==================== Logging ====================
176
+
177
+ def log_hook_error(hook_name: str, error: Exception | str | None = None) -> None:
154
178
  """Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
155
179
  import traceback
156
180
  try:
157
181
  log_file = hcom_path(SCRIPTS_DIR) / "hooks.log"
158
- log_file.parent.mkdir(parents=True, exist_ok=True)
159
182
  timestamp = datetime.now().isoformat()
160
183
  if error and isinstance(error, Exception):
161
184
  tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__))
@@ -168,43 +191,55 @@ def log_hook_error(hook_name: str, error: Exception | None = None):
168
191
  pass # Silent failure in error logging
169
192
 
170
193
  # ==================== Config Defaults ====================
194
+ # Config precedence: env var > ~/.hcom/config.env > defaults
195
+ # All config via HcomConfig dataclass (timeout, terminal, prompt, hints, tag, agent)
171
196
 
172
- # Type definition for configuration
173
- @dataclass
174
- class HcomConfig:
175
- terminal_command: str | None = None
176
- terminal_mode: str = "new_window"
177
- initial_prompt: str = "Say hi in chat"
178
- sender_name: str = "bigboss"
179
- sender_emoji: str = "🐳"
180
- cli_hints: str = ""
181
- wait_timeout: int = 1800 # 30mins
182
- max_message_size: int = 1048576 # 1MB
183
- max_messages_per_delivery: int = 50
184
- first_use_text: str = "Essential, concise messages only, say hi in hcom chat now"
185
- instance_hints: str = ""
186
- env_overrides: dict = field(default_factory=dict)
187
- auto_watch: bool = True # Auto-launch watch dashboard after open
188
-
189
- DEFAULT_CONFIG = HcomConfig()
190
-
191
- _config = None
192
-
193
- # Generate env var mappings from dataclass fields (except env_overrides)
194
- HOOK_SETTINGS = {
195
- field: f"HCOM_{field.upper()}"
196
- for field in DEFAULT_CONFIG.__dataclass_fields__
197
- if field != 'env_overrides'
198
- }
197
+ # Constants (not configurable)
198
+ MAX_MESSAGE_SIZE = 1048576 # 1MB
199
+ MAX_MESSAGES_PER_DELIVERY = 50
200
+ SENDER = 'bigboss'
201
+ SENDER_EMOJI = '🐳'
202
+ SKIP_HISTORY = True # New instances start at current log position (skip old messages)
199
203
 
200
204
  # Path constants
201
205
  LOG_FILE = "hcom.log"
202
206
  INSTANCES_DIR = "instances"
203
207
  LOGS_DIR = "logs"
204
208
  SCRIPTS_DIR = "scripts"
205
- CONFIG_FILE = "config.json"
209
+ CONFIG_FILE = "config.env"
206
210
  ARCHIVE_DIR = "archive"
207
211
 
212
+ # Hook type constants
213
+ ACTIVE_HOOK_TYPES = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop', 'Notification', 'SessionEnd']
214
+ LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES + ['PostToolUse'] # For backward compatibility cleanup
215
+ HOOK_COMMANDS = ['sessionstart', 'userpromptsubmit', 'pre', 'poll', 'notify', 'sessionend']
216
+ LEGACY_HOOK_COMMANDS = HOOK_COMMANDS + ['post']
217
+
218
+ # Hook removal patterns - used by _remove_hcom_hooks_from_settings()
219
+ # Dynamically build from LEGACY_HOOK_COMMANDS to match current and legacy hook formats
220
+ _HOOK_ARGS_PATTERN = '|'.join(LEGACY_HOOK_COMMANDS)
221
+ HCOM_HOOK_PATTERNS = [
222
+ re.compile(r'\$\{?HCOM'), # Current: Environment variable ${HCOM:-...}
223
+ re.compile(r'\bHCOM_ACTIVE.*hcom\.py'), # LEGACY: Unix HCOM_ACTIVE conditional
224
+ re.compile(r'IF\s+"%HCOM_ACTIVE%"'), # LEGACY: Windows HCOM_ACTIVE conditional
225
+ re.compile(rf'\bhcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: Direct hcom command
226
+ re.compile(rf'\buvx\s+hcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: uvx hcom command
227
+ re.compile(rf'hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: hcom.py with optional quote
228
+ re.compile(rf'["\'][^"\']*hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b(?=\s|$)'), # LEGACY: Quoted path
229
+ re.compile(r'sh\s+-c.*hcom'), # LEGACY: Shell wrapper
230
+ ]
231
+
232
+ # PreToolUse hook pattern - matches hcom commands for session_id injection and auto-approval
233
+ # - hcom send (any args)
234
+ # - hcom stop (no args) | hcom start (no args)
235
+ # - hcom help | hcom --help | hcom -h
236
+ # - hcom watch --status | hcom watch --launch
237
+ # Negative lookahead (?!\s+[-\w]) ensures stop/start not followed by arguments or flags
238
+ HCOM_COMMAND_PATTERN = re.compile(
239
+ r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+'
240
+ r'(?:send\b|(?:stop|start)(?!\s+[-\w])|(?:help|--help|-h)\b|watch\s+(?:--status|--launch)\b)'
241
+ )
242
+
208
243
  # ==================== File System Utilities ====================
209
244
 
210
245
  def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
@@ -216,9 +251,21 @@ def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
216
251
  path.parent.mkdir(parents=True, exist_ok=True)
217
252
  return path
218
253
 
254
+ def ensure_hcom_directories() -> bool:
255
+ """Ensure all critical HCOM directories exist. Idempotent, safe to call repeatedly.
256
+ Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
257
+ Returns True on success, False on failure."""
258
+ try:
259
+ for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, ARCHIVE_DIR]:
260
+ hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
261
+ return True
262
+ except (OSError, PermissionError):
263
+ return False
264
+
219
265
  def atomic_write(filepath: str | Path, content: str) -> bool:
220
266
  """Write content to file atomically to prevent corruption (now with NEW and IMPROVED (wow!) Windows retry logic (cool!!!)). Returns True on success, False on failure."""
221
267
  filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
268
+ filepath.parent.mkdir(parents=True, exist_ok=True)
222
269
 
223
270
  for attempt in range(3):
224
271
  with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
@@ -297,11 +344,23 @@ def load_instance_position(instance_name: str) -> dict[str, Any]:
297
344
  def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
298
345
  """Save position data for a single instance. Returns True on success, False on failure."""
299
346
  try:
300
- instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json", ensure_parent=True)
347
+ instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json")
301
348
  return atomic_write(instance_file, json.dumps(data, indent=2))
302
349
  except (OSError, PermissionError, ValueError):
303
350
  return False
304
351
 
352
+ def get_claude_settings_path() -> Path:
353
+ """Get path to global Claude settings file"""
354
+ return Path.home() / '.claude' / 'settings.json'
355
+
356
+ def load_settings_json(settings_path: Path, default: Any = None) -> dict[str, Any] | None:
357
+ """Load and parse settings JSON file with retry logic"""
358
+ return read_file_with_retry(
359
+ settings_path,
360
+ lambda f: json.load(f),
361
+ default=default
362
+ )
363
+
305
364
  def load_all_positions() -> dict[str, dict[str, Any]]:
306
365
  """Load positions from all instance files"""
307
366
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -326,132 +385,354 @@ def clear_all_positions() -> None:
326
385
  if instances_dir.exists():
327
386
  for f in instances_dir.glob('*.json'):
328
387
  f.unlink()
329
- # Clean up orphaned mapping files
330
- for f in instances_dir.glob('.launch_map_*'):
331
- f.unlink(missing_ok=True)
332
- else:
333
- instances_dir.mkdir(exist_ok=True)
334
388
 
335
389
  # ==================== Configuration System ====================
336
390
 
337
- def get_cached_config():
338
- """Get cached configuration, loading if needed"""
391
+ @dataclass
392
+ class HcomConfig:
393
+ """HCOM configuration with validation. Load priority: env → file → defaults"""
394
+ timeout: int = 1800
395
+ terminal: str = 'new'
396
+ prompt: str = 'say hi in hcom chat'
397
+ hints: str = ''
398
+ tag: str = ''
399
+ agent: str = 'generic'
400
+
401
+ def __post_init__(self):
402
+ """Validate configuration on construction"""
403
+ errors = self.validate()
404
+ if errors:
405
+ raise ValueError(f"Invalid config:\n" + "\n".join(f" - {e}" for e in errors))
406
+
407
+ def validate(self) -> list[str]:
408
+ """Validate all fields, return list of errors"""
409
+ errors = []
410
+
411
+ # Validate timeout
412
+ # Validate timeout (bool is subclass of int in Python, must check explicitly)
413
+ if isinstance(self.timeout, bool):
414
+ errors.append(f"timeout must be an integer, not boolean (got {self.timeout})")
415
+ elif not isinstance(self.timeout, int):
416
+ errors.append(f"timeout must be an integer, got {type(self.timeout).__name__}")
417
+ elif not 1 <= self.timeout <= 86400:
418
+ errors.append(f"timeout must be 1-86400 seconds (24 hours), got {self.timeout}")
419
+
420
+ # Validate terminal
421
+ if not isinstance(self.terminal, str):
422
+ errors.append(f"terminal must be a string, got {type(self.terminal).__name__}")
423
+ else:
424
+ valid_modes = ('new', 'here', 'print')
425
+ if self.terminal not in valid_modes and '{script}' not in self.terminal:
426
+ errors.append(
427
+ f"terminal must be one of {valid_modes} or custom command with {{script}}, "
428
+ f"got '{self.terminal}'"
429
+ )
430
+
431
+ # Validate tag (only alphanumeric and hyphens - security: prevent log delimiter injection)
432
+ if not isinstance(self.tag, str):
433
+ errors.append(f"tag must be a string, got {type(self.tag).__name__}")
434
+ elif self.tag and not re.match(r'^[a-zA-Z0-9-]+$', self.tag):
435
+ errors.append("tag can only contain letters, numbers, and hyphens")
436
+
437
+ # Validate agent
438
+ if not isinstance(self.agent, str):
439
+ errors.append(f"agent must be a string, got {type(self.agent).__name__}")
440
+
441
+ return errors
442
+
443
+ @classmethod
444
+ def load(cls) -> 'HcomConfig':
445
+ """Load config with precedence: env var → file → defaults"""
446
+ # Ensure config file exists
447
+ config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
448
+ created_config = False
449
+ if not config_path.exists():
450
+ _write_default_config(config_path)
451
+ created_config = True
452
+
453
+ # Warn once if legacy config.json still exists when creating config.env
454
+ legacy_config = hcom_path('config.json')
455
+ if created_config and legacy_config.exists():
456
+ print(
457
+ format_error(
458
+ "Found legacy ~/.hcom/config.json; new config file is: ~/.hcom/config.env."
459
+ ),
460
+ file=sys.stderr,
461
+ )
462
+
463
+ # Parse config file once
464
+ file_config = _parse_env_file(config_path) if config_path.exists() else {}
465
+
466
+ def get_var(key: str) -> str | None:
467
+ """Get variable with precedence: env → file"""
468
+ if key in os.environ:
469
+ return os.environ[key]
470
+ if key in file_config:
471
+ return file_config[key]
472
+ return None
473
+
474
+ data = {}
475
+
476
+ # Load timeout (requires int conversion)
477
+ timeout_str = get_var('HCOM_TIMEOUT')
478
+ if timeout_str is not None:
479
+ try:
480
+ data['timeout'] = int(timeout_str)
481
+ except (ValueError, TypeError):
482
+ pass # Use default
483
+
484
+ # Load string values
485
+ terminal = get_var('HCOM_TERMINAL')
486
+ if terminal is not None:
487
+ data['terminal'] = terminal
488
+ prompt = get_var('HCOM_PROMPT')
489
+ if prompt is not None:
490
+ data['prompt'] = prompt
491
+ hints = get_var('HCOM_HINTS')
492
+ if hints is not None:
493
+ data['hints'] = hints
494
+ tag = get_var('HCOM_TAG')
495
+ if tag is not None:
496
+ data['tag'] = tag
497
+ agent = get_var('HCOM_AGENT')
498
+ if agent is not None:
499
+ data['agent'] = agent
500
+
501
+ return cls(**data) # Validation happens in __post_init__
502
+
503
+ def _parse_env_file(config_path: Path) -> dict[str, str]:
504
+ """Parse ENV file (KEY=VALUE format) with security validation"""
505
+ config = {}
506
+
507
+ # Dangerous shell metacharacters that enable command injection
508
+ DANGEROUS_CHARS = ['`', '$', ';', '|', '&', '\n', '\r']
509
+
510
+ try:
511
+ content = config_path.read_text(encoding='utf-8')
512
+ for line in content.splitlines():
513
+ line = line.strip()
514
+ if not line or line.startswith('#'):
515
+ continue
516
+ if '=' in line:
517
+ key, _, value = line.partition('=')
518
+ key = key.strip()
519
+ value = value.strip()
520
+
521
+ # Security: Validate HCOM_TERMINAL for command injection
522
+ if key == 'HCOM_TERMINAL':
523
+ if any(c in value for c in DANGEROUS_CHARS):
524
+ print(
525
+ f"Warning: Unsafe characters in HCOM_TERMINAL "
526
+ f"({', '.join(repr(c) for c in DANGEROUS_CHARS if c in value)}), "
527
+ f"ignoring custom terminal command",
528
+ file=sys.stderr
529
+ )
530
+ continue
531
+ # Additional check: custom commands must contain {script} placeholder
532
+ if value not in ('new', 'here', 'print') and '{script}' not in value:
533
+ print(
534
+ f"Warning: HCOM_TERMINAL custom command must include {{script}} placeholder, "
535
+ f"ignoring",
536
+ file=sys.stderr
537
+ )
538
+ continue
539
+
540
+ # Remove outer quotes only if they match
541
+ if len(value) >= 2:
542
+ if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
543
+ value = value[1:-1]
544
+ if key:
545
+ config[key] = value
546
+ except (FileNotFoundError, PermissionError, UnicodeDecodeError):
547
+ pass
548
+ return config
549
+
550
+ def _write_default_config(config_path: Path) -> None:
551
+ """Write default config file with documentation"""
552
+ header = """# HCOM Configuration
553
+ #
554
+ # All HCOM_* settings can be set here (persistent) or via environment variables (temporary).
555
+ # Environment variables override config file values.
556
+ #
557
+ # HCOM settings:
558
+ # HCOM_TIMEOUT - Instance Stop hook wait timeout in seconds (default: 1800)
559
+ # HCOM_TERMINAL - Terminal mode: "new", "here", "print", or custom command with {script}
560
+ # HCOM_PROMPT - Initial prompt for new instances (empty = no auto prompt)
561
+ # HCOM_HINTS - Text appended to all messages received by instances
562
+ # HCOM_TAG - Group tag for instances (creates tag-* instances)
563
+ # HCOM_AGENT - Claude code subagent from .claude/agents/, comma-separated for multiple
564
+ #
565
+ # NOTE: Inline comments are not supported. Use separate comment lines.
566
+ #
567
+ # Claude Code settings (passed to Claude instances):
568
+ # ANTHROPIC_MODEL=opus
569
+ # Any other Claude Code environment variable
570
+ #
571
+ # Custom terminal examples:
572
+ # HCOM_TERMINAL="wezterm start -- bash {script}"
573
+ # HCOM_TERMINAL="kitty -e bash {script}"
574
+ #
575
+ """
576
+ defaults = [
577
+ 'HCOM_TIMEOUT=1800',
578
+ 'HCOM_TERMINAL=new',
579
+ 'HCOM_PROMPT=say hi in hcom chat',
580
+ 'HCOM_HINTS=',
581
+ ]
582
+ try:
583
+ atomic_write(config_path, header + '\n'.join(defaults) + '\n')
584
+ except Exception:
585
+ pass
586
+
587
+ # Global config instance (cached)
588
+ _config: HcomConfig | None = None
589
+
590
+ def get_config() -> HcomConfig:
591
+ """Get cached config, loading if needed"""
339
592
  global _config
340
593
  if _config is None:
341
- _config = _load_config_from_file()
594
+ _config = HcomConfig.load()
342
595
  return _config
343
596
 
344
- def _load_config_from_file() -> dict:
345
- """Load configuration from ~/.hcom/config.json"""
346
- config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
597
+ def _build_quoted_invocation() -> str:
598
+ """Build properly quoted python + script path for current platform"""
599
+ python_path = sys.executable
600
+ script_path = str(Path(__file__).resolve())
601
+
602
+ if IS_WINDOWS:
603
+ if ' ' in python_path or ' ' in script_path:
604
+ return f'"{python_path}" "{script_path}"'
605
+ return f'{python_path} {script_path}'
606
+ else:
607
+ return f'{shlex.quote(python_path)} {shlex.quote(script_path)}'
608
+
609
+ def get_hook_command() -> tuple[str, dict[str, Any]]:
610
+ """Get hook command - hooks always run, Python code gates participation
347
611
 
348
- # Start with default config as dict
349
- config_dict = asdict(DEFAULT_CONFIG)
612
+ Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
613
+ Participation is controlled by enabled flag in instance JSON files.
614
+ """
615
+ if IS_WINDOWS:
616
+ # Windows: use python path directly
617
+ return _build_quoted_invocation(), {}
618
+ else:
619
+ # Unix: Use HCOM env var from settings.json
620
+ return '${HCOM}', {}
621
+
622
+ def _detect_hcom_command_type() -> str:
623
+ """Detect how to invoke hcom based on execution context
624
+ Priority:
625
+ 1. short - If plugin enabled (plugin installs hcom binary to PATH)
626
+ 2. uvx - If running in uv-managed Python and uvx available
627
+ (works for both temporary uvx runs and permanent uv tool install)
628
+ 3. short - If hcom binary in PATH
629
+ 4. full - Fallback to full python invocation
630
+
631
+ Note: uvx hcom reuses uv tool install environments with zero overhead.
632
+ """
633
+ if is_plugin_active():
634
+ return 'short'
635
+ elif 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
636
+ return 'uvx'
637
+ elif shutil.which('hcom'):
638
+ return 'short'
639
+ else:
640
+ return 'full'
350
641
 
642
+ def check_version_once_daily() -> None:
643
+ """Check PyPI for newer version, show update command based on install method"""
644
+ cache_file = hcom_path() / '.version_check'
351
645
  try:
352
- if user_config := read_file_with_retry(
353
- config_path,
354
- lambda f: json.load(f),
355
- default=None
356
- ):
357
- # Merge user config into default config
358
- config_dict.update(user_config)
359
- elif not config_path.exists():
360
- # Write default config if file doesn't exist
361
- atomic_write(config_path, json.dumps(config_dict, indent=2))
362
- except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
363
- print("Warning: Cannot read config file, using defaults", file=sys.stderr)
364
- # config_dict already has defaults
365
-
366
- return config_dict
367
-
368
- def get_config_value(key: str, default: Any = None) -> Any:
369
- """Get config value with proper precedence:
370
- 1. Environment variable (if in HOOK_SETTINGS)
371
- 2. Config file
372
- 3. Default value
646
+ if cache_file.exists() and time.time() - cache_file.stat().st_mtime < 86400:
647
+ return
648
+
649
+ import urllib.request
650
+ with urllib.request.urlopen('https://pypi.org/pypi/hcom/json', timeout=2) as f:
651
+ data = json.load(f)
652
+ latest = data['info']['version']
653
+
654
+ # Simple version comparison (tuple of ints)
655
+ def parse_version(v: str) -> tuple:
656
+ return tuple(int(x) for x in v.split('.') if x.isdigit())
657
+
658
+ if parse_version(latest) > parse_version(__version__):
659
+ # Use existing detection to show correct update command
660
+ if _detect_hcom_command_type() == 'uvx':
661
+ update_cmd = "uv tool upgrade hcom"
662
+ else:
663
+ update_cmd = "pip install -U hcom"
664
+
665
+ print(f"→ hcom v{latest} available: {update_cmd}", file=sys.stderr)
666
+
667
+ cache_file.touch() # Update cache
668
+ except:
669
+ pass # Silent fail on network/parse errors
670
+
671
+ def _build_hcom_env_value() -> str:
672
+ """Build the value for settings['env']['HCOM'] based on current execution context
673
+ Uses build_hcom_command() without caching for fresh detection on every call.
373
674
  """
374
- if key in HOOK_SETTINGS:
375
- env_var = HOOK_SETTINGS[key]
376
- if (env_value := os.environ.get(env_var)) is not None:
377
- # Type conversion based on key
378
- if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
379
- try:
380
- return int(env_value)
381
- except ValueError:
382
- # Invalid integer - fall through to config/default
383
- pass
384
- elif key == 'auto_watch':
385
- return env_value.lower() in ('true', '1', 'yes', 'on')
675
+ return build_hcom_command(None)
676
+
677
+ def build_hcom_command(instance_name: str | None = None) -> str:
678
+ """Build base hcom command - caches PATH check in instance file on first use"""
679
+ # Determine command type (cached or detect)
680
+ cmd_type = None
681
+ if instance_name:
682
+ data = load_instance_position(instance_name)
683
+ if data.get('session_id'):
684
+ if 'hcom_cmd_type' not in data:
685
+ cmd_type = _detect_hcom_command_type()
686
+ data['hcom_cmd_type'] = cmd_type
687
+ save_instance_position(instance_name, data)
386
688
  else:
387
- # String values - return as-is
388
- return env_value
689
+ cmd_type = data.get('hcom_cmd_type')
389
690
 
390
- config = get_cached_config()
391
- return config.get(key, default)
691
+ if not cmd_type:
692
+ cmd_type = _detect_hcom_command_type()
392
693
 
393
- def get_hook_command():
394
- """Get hook command with silent fallback
395
-
396
- Uses ${HCOM:-true} for clean paths, conditional for paths with spaces.
397
- Both approaches exit silently (code 0) when not launched via 'hcom open'.
398
- """
399
- python_path = sys.executable
400
- script_path = str(Path(__file__).resolve())
401
-
402
- if IS_WINDOWS:
403
- # Windows cmd.exe syntax - no parentheses so arguments append correctly
404
- if ' ' in python_path or ' ' in script_path:
405
- return f'IF "%HCOM_ACTIVE%"=="1" "{python_path}" "{script_path}"', {}
406
- return f'IF "%HCOM_ACTIVE%"=="1" {python_path} {script_path}', {}
407
- elif ' ' in python_path or ' ' in script_path:
408
- # Unix with spaces: use conditional check
409
- escaped_python = shlex.quote(python_path)
410
- escaped_script = shlex.quote(script_path)
411
- return f'[ "${{HCOM_ACTIVE}}" = "1" ] && {escaped_python} {escaped_script} || true', {}
694
+ # Build command based on type
695
+ if cmd_type == 'short':
696
+ return 'hcom'
697
+ elif cmd_type == 'uvx':
698
+ return 'uvx hcom'
412
699
  else:
413
- # Unix clean paths: use environment variable
414
- return '${HCOM:-true}', {}
700
+ # Full path fallback
701
+ return _build_quoted_invocation()
415
702
 
416
- def build_send_command(example_msg: str = '') -> str:
417
- """Build send command string - checks if $HCOM exists, falls back to full path"""
703
+ def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
704
+ """Build send command - caches PATH check in instance file on first use"""
418
705
  msg = f" '{example_msg}'" if example_msg else ''
419
- if os.environ.get('HCOM'):
420
- return f'eval "$HCOM send{msg}"'
421
- python_path = shlex.quote(sys.executable)
422
- script_path = shlex.quote(str(Path(__file__).resolve()))
423
- return f'{python_path} {script_path} send{msg}'
424
-
425
- def build_claude_env():
426
- """Build environment variables for Claude instances"""
427
- env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
428
-
429
- # Get config file values
430
- config = get_cached_config()
431
-
432
- # Pass env vars only when they differ from config file values
433
- for config_key, env_var in HOOK_SETTINGS.items():
434
- actual_value = get_config_value(config_key) # Respects env var precedence
435
- config_file_value = config.get(config_key)
436
-
437
- # Only pass if different from config file (not default)
438
- if actual_value != config_file_value and actual_value is not None:
439
- env[env_var] = str(actual_value)
440
-
441
- # Still support env_overrides from config file
442
- env.update(config.get('env_overrides', {}))
443
-
444
- # Set HCOM only for clean paths (spaces handled differently)
445
- python_path = sys.executable
446
- script_path = str(Path(__file__).resolve())
447
- if ' ' not in python_path and ' ' not in script_path:
448
- env['HCOM'] = f'{python_path} {script_path}'
449
-
706
+ base_cmd = build_hcom_command(instance_name)
707
+ return f'{base_cmd} send{msg}'
708
+
709
+ def build_claude_env() -> dict[str, str]:
710
+ """Build environment variables for Claude instances
711
+
712
+ Passes current environment to Claude, with config.env providing defaults.
713
+ HCOM_* variables are filtered out (consumed by hcom, not passed to Claude).
714
+ """
715
+ env = {}
716
+
717
+ # Read config file directly for Claude Code env vars (non-HCOM_ keys)
718
+ config_path = hcom_path(CONFIG_FILE)
719
+ if config_path.exists():
720
+ file_config = _parse_env_file(config_path)
721
+ for key, value in file_config.items():
722
+ if not key.startswith('HCOM_'):
723
+ env[key] = str(value)
724
+
725
+ # Overlay with current environment (except HCOM_*)
726
+ # This ensures user's shell environment is respected
727
+ for key, value in os.environ.items():
728
+ if not key.startswith('HCOM_'):
729
+ env[key] = value
730
+
450
731
  return env
451
732
 
452
733
  # ==================== Message System ====================
453
734
 
454
- def validate_message(message: str) -> Optional[str]:
735
+ def validate_message(message: str) -> str | None:
455
736
  """Validate message size and content. Returns error message or None if valid."""
456
737
  if not message or not message.strip():
457
738
  return format_error("Message required")
@@ -460,32 +741,90 @@ def validate_message(message: str) -> Optional[str]:
460
741
  if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
461
742
  return format_error("Message contains control characters")
462
743
 
463
- max_size = get_config_value('max_message_size', 1048576)
464
- if len(message) > max_size:
465
- return format_error(f"Message too large (max {max_size} chars)")
744
+ if len(message) > MAX_MESSAGE_SIZE:
745
+ return format_error(f"Message too large (max {MAX_MESSAGE_SIZE} chars)")
466
746
 
467
747
  return None
468
748
 
469
749
  def send_message(from_instance: str, message: str) -> bool:
470
750
  """Send a message to the log"""
471
751
  try:
472
- log_file = hcom_path(LOG_FILE, ensure_parent=True)
473
-
752
+ log_file = hcom_path(LOG_FILE)
753
+
474
754
  escaped_message = message.replace('|', '\\|')
475
755
  escaped_from = from_instance.replace('|', '\\|')
476
-
756
+
477
757
  timestamp = datetime.now().isoformat()
478
758
  line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
479
-
759
+
480
760
  with open(log_file, 'a', encoding='utf-8') as f:
481
761
  f.write(line)
482
762
  f.flush()
483
-
763
+
484
764
  return True
485
765
  except Exception:
486
766
  return False
487
767
 
488
- def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: Optional[list[str]] = None) -> bool:
768
+ def build_hcom_bootstrap_text(instance_name: str) -> str:
769
+ """Build comprehensive HCOM bootstrap context for instances"""
770
+ hcom_cmd = build_hcom_command(instance_name=instance_name)
771
+
772
+ # Add command override notice if not using short form
773
+ command_notice = ""
774
+ if hcom_cmd != "hcom":
775
+ command_notice = f"""IMPORTANT:
776
+ The hcom command in this environment is: {hcom_cmd}
777
+ Replace all mentions of "hcom" below with this command.
778
+
779
+ """
780
+
781
+ # Add tag-specific notice if instance is tagged
782
+ tag = get_config().tag
783
+ tag_notice = ""
784
+ if tag:
785
+ tag_notice = f"""
786
+ GROUP TAG: You are in the '{tag}' group.
787
+ - To message your group: hcom send "@{tag} your message"
788
+ - Group messages are targeted - only instances with an alias starting with {tag}-* receive them
789
+ - 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.
790
+ """
791
+
792
+
793
+ return f"""{command_notice}{tag_notice}
794
+ [HCOM SESSION CONFIG]
795
+ HCOM is a communication tool for you, other claude code instances, and the user.
796
+ Your HCOM alias for this session: {instance_name}
797
+
798
+ **Your HCOM Tools:**
799
+ hcom send "msg" / "@alias msg" / "@tag msg"
800
+ hcom watch --status → Monitor participants in JSON
801
+ hcom watch --launch → Open dashboard for user in new terminal
802
+ hcom start/stop → join/leave HCOM chat
803
+ hcom <num> → Launch instances in new terminal (always run 'hcom help' first)
804
+
805
+ Commands relevant to user: hcom <num>/start/stop/watch (dont announce others to user)
806
+ Context: User runs 'hcom watch' in new terminal, you run hcom watch --launch for the user ("I'll open 'hcom watch' for you").
807
+
808
+ **Receiving Messages:**
809
+ Format: [new message] sender → you: content
810
+ direct: "@alias" targets a specific instance.
811
+ tag: "@api message" targets all api-* instances.
812
+ Arrives automatically via hooks/bash. {{"decision": "block"}} text is normal operation. No proactive checking needed.
813
+
814
+ **Response Routing:**
815
+ HCOM message (via hook/bash) → Respond with hcom send
816
+ User message (in chat) → Respond normally
817
+ Treat messages from hcom with the same care as user messages.
818
+
819
+ Authority: Prioritize @{SENDER} over other participants.
820
+
821
+ This is context for YOUR upcoming command execution. User cannot see this.
822
+ Report connection results and overview of relevant hcom info to user using first-person: "I'm connected as {instance_name}"
823
+ """
824
+
825
+
826
+
827
+ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
489
828
  """Check if message should be delivered based on @-mentions"""
490
829
  text = msg['message']
491
830
 
@@ -504,8 +843,7 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
504
843
  return True
505
844
 
506
845
  # Check if any mention is for the CLI sender (bigboss)
507
- sender_name = get_config_value('sender_name', 'bigboss')
508
- sender_mentioned = any(sender_name.lower().startswith(mention.lower()) for mention in mentions)
846
+ sender_mentioned = any(SENDER.lower().startswith(mention.lower()) for mention in mentions)
509
847
 
510
848
  # If we have all_instance_names, check if ANY mention matches ANY instance or sender
511
849
  if all_instance_names:
@@ -521,69 +859,13 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
521
859
 
522
860
  # ==================== Parsing & Utilities ====================
523
861
 
524
- def parse_open_args(args: list[str]) -> tuple[list[str], Optional[str], list[str], bool]:
525
- """Parse arguments for open command
526
-
527
- Returns:
528
- tuple: (instances, prefix, claude_args, background)
529
- instances: list of agent names or 'generic'
530
- prefix: team name prefix or None
531
- claude_args: additional args to pass to claude
532
- background: bool, True if --background or -p flag
533
- """
534
- instances = []
535
- prefix = None
536
- claude_args = []
537
- background = False
538
-
539
- i = 0
540
- while i < len(args):
541
- arg = args[i]
542
-
543
- if arg == '--prefix':
544
- if i + 1 >= len(args):
545
- raise ValueError(format_error('--prefix requires an argument'))
546
- prefix = args[i + 1]
547
- if '|' in prefix:
548
- raise ValueError(format_error('Prefix cannot contain pipe characters'))
549
- i += 2
550
- elif arg == '--claude-args':
551
- # Next argument contains claude args as a string
552
- if i + 1 >= len(args):
553
- raise ValueError(format_error('--claude-args requires an argument'))
554
- claude_args = shlex.split(args[i + 1])
555
- i += 2
556
- elif arg == '--background' or arg == '-p':
557
- background = True
558
- i += 1
559
- else:
560
- try:
561
- count = int(arg)
562
- if count < 0:
563
- raise ValueError(format_error(f"Cannot launch negative instances: {count}"))
564
- if count > 100:
565
- raise ValueError(format_error(f"Too many instances requested: {count}", "Maximum 100 instances at once"))
566
- instances.extend(['generic'] * count)
567
- except ValueError as e:
568
- if "Cannot launch" in str(e) or "Too many instances" in str(e):
569
- raise
570
- # Not a number, treat as agent name
571
- instances.append(arg)
572
- i += 1
573
-
574
- if not instances:
575
- instances = ['generic']
576
-
577
- return instances, prefix, claude_args, background
578
-
579
862
  def extract_agent_config(content: str) -> dict[str, str]:
580
863
  """Extract configuration from agent YAML frontmatter"""
581
864
  if not content.startswith('---'):
582
865
  return {}
583
866
 
584
867
  # Find YAML section between --- markers
585
- yaml_end = content.find('\n---', 3)
586
- if yaml_end < 0:
868
+ if (yaml_end := content.find('\n---', 3)) < 0:
587
869
  return {} # No closing marker
588
870
 
589
871
  yaml_section = content[3:yaml_end]
@@ -682,7 +964,7 @@ def strip_frontmatter(content: str) -> str:
682
964
  return '\n'.join(lines[i+1:]).strip()
683
965
  return content
684
966
 
685
- def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) -> str:
967
+ def get_display_name(session_id: str | None, tag: str | None = None) -> str:
686
968
  """Get display name for instance using session_id"""
687
969
  syls = ['ka', 'ko', 'ma', 'mo', 'na', 'no', 'ra', 'ro', 'sa', 'so', 'ta', 'to', 'va', 'vo', 'za', 'zo', 'be', 'de', 'fe', 'ge', 'le', 'me', 'ne', 're', 'se', 'te', 've', 'we', 'hi']
688
970
  # Phonetic letters (5 per syllable, matches syls order)
@@ -726,42 +1008,52 @@ def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) ->
726
1008
  # session_id is required - fail gracefully
727
1009
  raise ValueError("session_id required for instance naming")
728
1010
 
729
- if prefix:
730
- return f"{prefix}-{base_name}"
1011
+ if tag:
1012
+ # Security: Sanitize tag to prevent log delimiter injection (defense-in-depth)
1013
+ # Remove dangerous characters that could break message log parsing
1014
+ sanitized_tag = ''.join(c for c in tag if c not in '|\n\r\t')
1015
+ if not sanitized_tag:
1016
+ raise ValueError("Tag contains only invalid characters")
1017
+ if sanitized_tag != tag:
1018
+ print(f"Warning: Tag contained invalid characters, sanitized to '{sanitized_tag}'", file=sys.stderr)
1019
+ return f"{sanitized_tag}-{base_name}"
731
1020
  return base_name
732
1021
 
733
- def _remove_hcom_hooks_from_settings(settings):
1022
+ def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str, dict | None]:
1023
+ """
1024
+ Resolve instance name for a session_id.
1025
+ Searches existing instances first (reuses if found), generates new name if not found.
1026
+
1027
+ Returns: (instance_name, existing_data_or_none)
1028
+ """
1029
+ instances_dir = hcom_path(INSTANCES_DIR)
1030
+
1031
+ # Search for existing instance with this session_id
1032
+ if session_id and instances_dir.exists():
1033
+ for instance_file in instances_dir.glob("*.json"):
1034
+ try:
1035
+ data = load_instance_position(instance_file.stem)
1036
+ if session_id == data.get('session_id'):
1037
+ return instance_file.stem, data
1038
+ except (json.JSONDecodeError, OSError, KeyError):
1039
+ continue
1040
+
1041
+ # Not found - generate new name
1042
+ instance_name = get_display_name(session_id, tag)
1043
+ return instance_name, None
1044
+
1045
+ def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
734
1046
  """Remove hcom hooks from settings dict"""
735
1047
  if not isinstance(settings, dict) or 'hooks' not in settings:
736
1048
  return
737
-
1049
+
738
1050
  if not isinstance(settings['hooks'], dict):
739
1051
  return
740
-
1052
+
741
1053
  import copy
742
-
743
- # Patterns to match any hcom hook command
744
- # Modern hooks (patterns 1-2, 7): Match all hook types via env var or wrapper
745
- # - ${HCOM:-true} sessionstart/pre/stop/notify/userpromptsubmit
746
- # - [ "${HCOM_ACTIVE}" = "1" ] && ... hcom.py ... || true
747
- # - sh -c "[ ... ] && ... hcom ..."
748
- # Legacy hooks (patterns 3-6): Direct command invocation
749
- # - hcom pre/post/stop/notify/sessionstart/userpromptsubmit
750
- # - uvx hcom pre/post/stop/notify/sessionstart/userpromptsubmit
751
- # - /path/to/hcom.py pre/post/stop/notify/sessionstart/userpromptsubmit
752
- hcom_patterns = [
753
- r'\$\{?HCOM', # Environment variable (${HCOM:-true}) - all hook types
754
- r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with full path - all hook types
755
- r'\bhcom\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # Direct hcom command
756
- r'\buvx\s+hcom\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # uvx hcom command
757
- r'hcom\.py["\']?\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # hcom.py with optional quote
758
- r'["\'][^"\']*hcom\.py["\']?\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b(?=\s|$)', # Quoted path
759
- r'sh\s+-c.*hcom', # Shell wrapper with hcom
760
- ]
761
- compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
762
1054
 
763
1055
  # Check all hook types including PostToolUse for backward compatibility cleanup
764
- for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
1056
+ for event in LEGACY_HOOK_TYPES:
765
1057
  if event not in settings['hooks']:
766
1058
  continue
767
1059
 
@@ -780,7 +1072,7 @@ def _remove_hcom_hooks_from_settings(settings):
780
1072
  hook for hook in matcher_copy.get('hooks', [])
781
1073
  if not any(
782
1074
  pattern.search(hook.get('command', ''))
783
- for pattern in compiled_patterns
1075
+ for pattern in HCOM_HOOK_PATTERNS
784
1076
  )
785
1077
  ]
786
1078
 
@@ -796,9 +1088,16 @@ def _remove_hcom_hooks_from_settings(settings):
796
1088
  settings['hooks'][event] = updated_matchers
797
1089
  else:
798
1090
  del settings['hooks'][event]
799
-
800
1091
 
801
- def build_env_string(env_vars, format_type="bash"):
1092
+ # Remove HCOM from env section
1093
+ if 'env' in settings and isinstance(settings['env'], dict):
1094
+ settings['env'].pop('HCOM', None)
1095
+ # Clean up empty env dict
1096
+ if not settings['env']:
1097
+ del settings['env']
1098
+
1099
+
1100
+ def build_env_string(env_vars: dict[str, Any], format_type: str = "bash") -> str:
802
1101
  """Build environment variable string for bash shells"""
803
1102
  if format_type == "bash_export":
804
1103
  # Properly escape values for bash
@@ -807,7 +1106,7 @@ def build_env_string(env_vars, format_type="bash"):
807
1106
  return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
808
1107
 
809
1108
 
810
- def format_error(message: str, suggestion: Optional[str] = None) -> str:
1109
+ def format_error(message: str, suggestion: str | None = None) -> str:
811
1110
  """Format error message consistently"""
812
1111
  base = f"Error: {message}"
813
1112
  if suggestion:
@@ -815,14 +1114,14 @@ def format_error(message: str, suggestion: Optional[str] = None) -> str:
815
1114
  return base
816
1115
 
817
1116
 
818
- def has_claude_arg(claude_args, arg_names, arg_prefixes):
1117
+ def has_claude_arg(claude_args: list[str] | None, arg_names: list[str], arg_prefixes: tuple[str, ...]) -> bool:
819
1118
  """Check if argument already exists in claude_args"""
820
- return claude_args and any(
1119
+ return bool(claude_args and any(
821
1120
  arg in arg_names or arg.startswith(arg_prefixes)
822
1121
  for arg in claude_args
823
- )
1122
+ ))
824
1123
 
825
- def build_claude_command(agent_content: Optional[str] = None, claude_args: Optional[list[str]] = None, initial_prompt: str = "Say hi in chat", model: Optional[str] = None, tools: Optional[str] = None) -> tuple[str, Optional[str]]:
1124
+ def build_claude_command(agent_content: str | None = None, claude_args: list[str] | None = None, initial_prompt: str = "Say hi in chat", model: str | None = None, tools: str | None = None) -> tuple[str, str | None]:
826
1125
  """Build Claude command with proper argument handling
827
1126
  Returns tuple: (command_string, temp_file_path_or_none)
828
1127
  For agent content, writes to temp file and uses cat to read it.
@@ -848,7 +1147,6 @@ def build_claude_command(agent_content: Optional[str] = None, claude_args: Optio
848
1147
  if agent_content:
849
1148
  # Create agent files in scripts directory for unified cleanup
850
1149
  scripts_dir = hcom_path(SCRIPTS_DIR)
851
- scripts_dir.mkdir(parents=True, exist_ok=True)
852
1150
  temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
853
1151
  prefix='hcom_agent_', dir=str(scripts_dir))
854
1152
  temp_file.write(agent_content)
@@ -862,27 +1160,23 @@ def build_claude_command(agent_content: Optional[str] = None, claude_args: Optio
862
1160
 
863
1161
  cmd_parts.append(flag)
864
1162
  cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
865
-
866
- if claude_args or agent_content:
867
- cmd_parts.append('--')
868
-
869
- # Quote initial prompt normally
870
- cmd_parts.append(shlex.quote(initial_prompt))
871
-
1163
+
1164
+ # Add initial prompt if non-empty
1165
+ if initial_prompt:
1166
+ cmd_parts.append(shlex.quote(initial_prompt))
1167
+
872
1168
  return ' '.join(cmd_parts), temp_file_path
873
1169
 
874
- def create_bash_script(script_file, env, cwd, command_str, background=False):
1170
+ def create_bash_script(script_file: str, env: dict[str, Any], cwd: str | None, command_str: str, background: bool = False) -> None:
875
1171
  """Create a bash script for terminal launch
876
1172
  Scripts provide uniform execution across all platforms/terminals.
877
1173
  Cleanup behavior:
878
1174
  - Normal scripts: append 'rm -f' command for self-deletion
879
- - Background scripts: persist until `hcom clear` housekeeping (24 hours)
1175
+ - Background scripts: persist until `hcom reset logs` cleanup (24 hours)
880
1176
  - Agent scripts: treated like background (contain 'hcom_agent_')
881
1177
  """
882
1178
  try:
883
- # Ensure parent directory exists
884
1179
  script_path = Path(script_file)
885
- script_path.parent.mkdir(parents=True, exist_ok=True)
886
1180
  except (OSError, IOError) as e:
887
1181
  raise Exception(f"Cannot create script directory: {e}")
888
1182
 
@@ -946,7 +1240,7 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
946
1240
  if platform.system() != 'Windows':
947
1241
  os.chmod(script_file, 0o755)
948
1242
 
949
- def find_bash_on_windows():
1243
+ def find_bash_on_windows() -> str | None:
950
1244
  """Find Git Bash on Windows, avoiding WSL's bash launcher"""
951
1245
  # Build prioritized list of bash candidates
952
1246
  candidates = []
@@ -961,8 +1255,7 @@ def find_bash_on_windows():
961
1255
  ])
962
1256
 
963
1257
  # 2. Portable Git installation
964
- local_appdata = os.environ.get('LOCALAPPDATA', '')
965
- if local_appdata:
1258
+ if local_appdata := os.environ.get('LOCALAPPDATA', ''):
966
1259
  git_portable = Path(local_appdata) / 'Programs' / 'Git'
967
1260
  candidates.extend([
968
1261
  str(git_portable / 'usr' / 'bin' / 'bash.exe'),
@@ -970,8 +1263,7 @@ def find_bash_on_windows():
970
1263
  ])
971
1264
 
972
1265
  # 3. PATH bash (if not WSL's launcher)
973
- path_bash = shutil.which('bash')
974
- if path_bash and not path_bash.lower().endswith(r'system32\bash.exe'):
1266
+ if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
975
1267
  candidates.append(path_bash)
976
1268
 
977
1269
  # 4. Hardcoded fallbacks (last resort)
@@ -990,21 +1282,20 @@ def find_bash_on_windows():
990
1282
  return None
991
1283
 
992
1284
  # New helper functions for platform-specific terminal launching
993
- def get_macos_terminal_argv():
1285
+ def get_macos_terminal_argv() -> list[str]:
994
1286
  """Return macOS Terminal.app launch command as argv list."""
995
1287
  return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
996
1288
 
997
- def get_windows_terminal_argv():
1289
+ def get_windows_terminal_argv() -> list[str]:
998
1290
  """Return Windows terminal launcher as argv list."""
999
- bash_exe = find_bash_on_windows()
1000
- if not bash_exe:
1291
+ if not (bash_exe := find_bash_on_windows()):
1001
1292
  raise Exception(format_error("Git Bash not found"))
1002
1293
 
1003
1294
  if shutil.which('wt'):
1004
1295
  return ['wt', bash_exe, '{script}']
1005
1296
  return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
1006
1297
 
1007
- def get_linux_terminal_argv():
1298
+ def get_linux_terminal_argv() -> list[str] | None:
1008
1299
  """Return first available Linux terminal as argv list."""
1009
1300
  terminals = [
1010
1301
  ('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
@@ -1023,7 +1314,7 @@ def get_linux_terminal_argv():
1023
1314
 
1024
1315
  return None
1025
1316
 
1026
- def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
1317
+ def windows_hidden_popen(argv: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None, stdout: Any = None) -> subprocess.Popen:
1027
1318
  """Create hidden Windows process without console window."""
1028
1319
  if IS_WINDOWS:
1029
1320
  startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
@@ -1050,7 +1341,7 @@ PLATFORM_TERMINAL_GETTERS = {
1050
1341
  'Linux': get_linux_terminal_argv,
1051
1342
  }
1052
1343
 
1053
- def _parse_terminal_command(template, script_file):
1344
+ def _parse_terminal_command(template: str, script_file: str) -> list[str]:
1054
1345
  """Parse terminal command template safely to prevent shell injection.
1055
1346
  Parses the template FIRST, then replaces {script} placeholder in the
1056
1347
  parsed tokens. This avoids shell injection and handles paths with spaces.
@@ -1088,7 +1379,7 @@ def _parse_terminal_command(template, script_file):
1088
1379
 
1089
1380
  return replaced
1090
1381
 
1091
- def launch_terminal(command, env, cwd=None, background=False):
1382
+ def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, background: bool = False) -> str | bool | None:
1092
1383
  """Launch terminal with command using unified script-first approach
1093
1384
  Args:
1094
1385
  command: Command string from build_claude_command
@@ -1102,14 +1393,12 @@ def launch_terminal(command, env, cwd=None, background=False):
1102
1393
 
1103
1394
  # 1) Always create a script
1104
1395
  script_file = str(hcom_path(SCRIPTS_DIR,
1105
- f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh',
1106
- ensure_parent=True))
1396
+ f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'))
1107
1397
  create_bash_script(script_file, env, cwd, command_str, background)
1108
1398
 
1109
1399
  # 2) Background mode
1110
1400
  if background:
1111
1401
  logs_dir = hcom_path(LOGS_DIR)
1112
- logs_dir.mkdir(parents=True, exist_ok=True)
1113
1402
  log_file = logs_dir / env['HCOM_BACKGROUND']
1114
1403
 
1115
1404
  try:
@@ -1152,9 +1441,9 @@ def launch_terminal(command, env, cwd=None, background=False):
1152
1441
  return str(log_file)
1153
1442
 
1154
1443
  # 3) Terminal modes
1155
- terminal_mode = get_config_value('terminal_mode', 'new_window')
1444
+ terminal_mode = get_config().terminal
1156
1445
 
1157
- if terminal_mode == 'show_commands':
1446
+ if terminal_mode == 'print':
1158
1447
  # Print script path and contents
1159
1448
  try:
1160
1449
  with open(script_file, 'r', encoding='utf-8') as f:
@@ -1167,7 +1456,7 @@ def launch_terminal(command, env, cwd=None, background=False):
1167
1456
  print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
1168
1457
  return False
1169
1458
 
1170
- if terminal_mode == 'same_terminal':
1459
+ if terminal_mode == 'here':
1171
1460
  print("Launching Claude in current terminal...")
1172
1461
  if IS_WINDOWS:
1173
1462
  bash_exe = find_bash_on_windows()
@@ -1179,10 +1468,11 @@ def launch_terminal(command, env, cwd=None, background=False):
1179
1468
  result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
1180
1469
  return result.returncode == 0
1181
1470
 
1182
- # 4) New window mode
1183
- custom_cmd = get_config_value('terminal_command')
1471
+ # 4) New window or custom command mode
1472
+ # If terminal is not 'here' or 'print', it's either 'new' (platform default) or a custom command
1473
+ custom_cmd = None if terminal_mode == 'new' else terminal_mode
1184
1474
 
1185
- if not custom_cmd: # No string sentinel checks
1475
+ if not custom_cmd: # Platform default 'new' mode
1186
1476
  if is_termux():
1187
1477
  # Keep Termux as special case
1188
1478
  am_cmd = [
@@ -1201,8 +1491,7 @@ def launch_terminal(command, env, cwd=None, background=False):
1201
1491
 
1202
1492
  # Unified platform handling via helpers
1203
1493
  system = platform.system()
1204
- terminal_getter = PLATFORM_TERMINAL_GETTERS.get(system)
1205
- if not terminal_getter:
1494
+ if not (terminal_getter := PLATFORM_TERMINAL_GETTERS.get(system)):
1206
1495
  raise Exception(format_error(f"Unsupported platform: {system}"))
1207
1496
 
1208
1497
  custom_cmd = terminal_getter()
@@ -1249,18 +1538,30 @@ def launch_terminal(command, env, cwd=None, background=False):
1249
1538
  print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
1250
1539
  return False
1251
1540
 
1252
- def setup_hooks():
1253
- """Set up Claude hooks in current directory"""
1254
- claude_dir = Path.cwd() / '.claude'
1255
- claude_dir.mkdir(exist_ok=True)
1256
-
1257
- settings_path = claude_dir / 'settings.local.json'
1541
+ def setup_hooks() -> bool:
1542
+ """Set up Claude hooks globally in ~/.claude/settings.json"""
1543
+
1544
+ # TODO: Remove after v0.6.0 - cleanup legacy per-directory hooks
1258
1545
  try:
1259
- settings = read_file_with_retry(
1260
- settings_path,
1261
- lambda f: json.load(f),
1262
- default={}
1263
- )
1546
+ positions = load_all_positions()
1547
+ if positions:
1548
+ directories = set()
1549
+ for instance_data in positions.values():
1550
+ if isinstance(instance_data, dict) and 'directory' in instance_data:
1551
+ directories.add(instance_data['directory'])
1552
+ for directory in directories:
1553
+ if Path(directory).exists():
1554
+ cleanup_directory_hooks(Path(directory))
1555
+ except Exception:
1556
+ pass # Don't fail hook setup if cleanup fails
1557
+
1558
+ # Install to global user settings
1559
+ settings_path = get_claude_settings_path()
1560
+ settings_path.parent.mkdir(exist_ok=True)
1561
+ try:
1562
+ settings = load_settings_json(settings_path, default={})
1563
+ if settings is None:
1564
+ settings = {}
1264
1565
  except (json.JSONDecodeError, PermissionError) as e:
1265
1566
  raise Exception(format_error(f"Cannot read settings: {e}"))
1266
1567
 
@@ -1271,24 +1572,29 @@ def setup_hooks():
1271
1572
 
1272
1573
  # Get the hook command template
1273
1574
  hook_cmd_base, _ = get_hook_command()
1274
-
1275
- # Get wait_timeout (needed for Stop hook)
1276
- wait_timeout = get_config_value('wait_timeout', 1800)
1277
-
1278
- # Define all hooks (PostToolUse removed - causes API 400 errors)
1575
+
1576
+ # Define all hooks - must match ACTIVE_HOOK_TYPES
1577
+ # Format: (hook_type, matcher, command, timeout)
1279
1578
  hook_configs = [
1280
1579
  ('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
1281
1580
  ('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
1282
1581
  ('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
1283
- # ('PostToolUse', '.*', f'{hook_cmd_base} post', None), # DISABLED
1284
- ('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
1582
+ ('Stop', '', f'{hook_cmd_base} poll', 86400), # 24hr timeout max; internal timeout 30min default via config
1285
1583
  ('Notification', '', f'{hook_cmd_base} notify', None),
1584
+ ('SessionEnd', '', f'{hook_cmd_base} sessionend', None),
1286
1585
  ]
1287
-
1586
+
1587
+ # Validate hook_configs matches ACTIVE_HOOK_TYPES
1588
+ configured_types = [hook_type for hook_type, _, _, _ in hook_configs]
1589
+ if configured_types != ACTIVE_HOOK_TYPES:
1590
+ raise Exception(format_error(
1591
+ f"Hook configuration mismatch: {configured_types} != {ACTIVE_HOOK_TYPES}"
1592
+ ))
1593
+
1288
1594
  for hook_type, matcher, command, timeout in hook_configs:
1289
1595
  if hook_type not in settings['hooks']:
1290
1596
  settings['hooks'][hook_type] = []
1291
-
1597
+
1292
1598
  hook_dict = {
1293
1599
  'matcher': matcher,
1294
1600
  'hooks': [{
@@ -1298,9 +1604,16 @@ def setup_hooks():
1298
1604
  }
1299
1605
  if timeout is not None:
1300
1606
  hook_dict['hooks'][0]['timeout'] = timeout
1301
-
1607
+
1302
1608
  settings['hooks'][hook_type].append(hook_dict)
1303
-
1609
+
1610
+ # Set $HCOM environment variable for all Claude instances (vanilla + hcom-launched)
1611
+ if 'env' not in settings:
1612
+ settings['env'] = {}
1613
+
1614
+ # Set HCOM based on current execution context (uvx, hcom binary, or full path)
1615
+ settings['env']['HCOM'] = _build_hcom_env_value()
1616
+
1304
1617
  # Write settings atomically
1305
1618
  try:
1306
1619
  atomic_write(settings_path, json.dumps(settings, indent=2))
@@ -1313,104 +1626,50 @@ def setup_hooks():
1313
1626
 
1314
1627
  return True
1315
1628
 
1316
- def verify_hooks_installed(settings_path):
1317
- """Verify that HCOM hooks were installed correctly"""
1629
+ def verify_hooks_installed(settings_path: Path) -> bool:
1630
+ """Verify that HCOM hooks were installed correctly with correct commands"""
1318
1631
  try:
1319
- settings = read_file_with_retry(
1320
- settings_path,
1321
- lambda f: json.load(f),
1322
- default=None
1323
- )
1632
+ settings = load_settings_json(settings_path, default=None)
1324
1633
  if not settings:
1325
1634
  return False
1326
1635
 
1327
- # Check all hook types exist with HCOM commands (PostToolUse removed)
1636
+ # Check all hook types have correct commands (exactly one HCOM hook per type)
1328
1637
  hooks = settings.get('hooks', {})
1329
- for hook_type in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop', 'Notification']:
1330
- if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
1331
- for h in hooks.get(hook_type, [])):
1638
+ for hook_type, expected_cmd in zip(ACTIVE_HOOK_TYPES, HOOK_COMMANDS):
1639
+ hook_matchers = hooks.get(hook_type, [])
1640
+ if not hook_matchers:
1641
+ return False
1642
+
1643
+ # Count HCOM hooks for this type
1644
+ hcom_hook_count = 0
1645
+ for matcher in hook_matchers:
1646
+ for hook in matcher.get('hooks', []):
1647
+ command = hook.get('command', '')
1648
+ # Check for HCOM and the correct subcommand
1649
+ if ('${HCOM}' in command or 'hcom' in command.lower()) and expected_cmd in command:
1650
+ hcom_hook_count += 1
1651
+
1652
+ # Must have exactly one HCOM hook (not zero, not duplicates)
1653
+ if hcom_hook_count != 1:
1332
1654
  return False
1333
1655
 
1656
+ # Check that HCOM env var is set
1657
+ env = settings.get('env', {})
1658
+ if 'HCOM' not in env:
1659
+ return False
1660
+
1334
1661
  return True
1335
1662
  except Exception:
1336
1663
  return False
1337
1664
 
1338
- def is_interactive():
1665
+ def is_interactive() -> bool:
1339
1666
  """Check if running in interactive mode"""
1340
1667
  return sys.stdin.isatty() and sys.stdout.isatty()
1341
1668
 
1342
- def get_archive_timestamp():
1669
+ def get_archive_timestamp() -> str:
1343
1670
  """Get timestamp for archive files"""
1344
1671
  return datetime.now().strftime("%Y-%m-%d_%H%M%S")
1345
1672
 
1346
- def is_parent_alive(parent_pid=None):
1347
- """Check if parent process is alive"""
1348
- if parent_pid is None:
1349
- parent_pid = os.getppid()
1350
-
1351
- # Orphan detection - PID 1 == definitively orphaned
1352
- if parent_pid == 1:
1353
- return False
1354
-
1355
- result = is_process_alive(parent_pid)
1356
- return result
1357
-
1358
- def is_process_alive(pid):
1359
- """Check if a process with given PID exists - cross-platform"""
1360
- if pid is None:
1361
- return False
1362
-
1363
- try:
1364
- pid = int(pid)
1365
- except (TypeError, ValueError):
1366
- return False
1367
-
1368
- if IS_WINDOWS:
1369
- # Windows: Use Windows API to check process existence
1370
- try:
1371
- kernel32 = get_windows_kernel32() # Use cached kernel32 instance
1372
- if not kernel32:
1373
- return False
1374
-
1375
- # Try limited permissions first (more likely to succeed on Vista+)
1376
- handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
1377
- error = kernel32.GetLastError()
1378
-
1379
- if not handle: # Check for None or 0
1380
- # ERROR_ACCESS_DENIED (5) means process exists but no permission
1381
- if error == ERROR_ACCESS_DENIED:
1382
- return True
1383
-
1384
- # Try fallback with broader permissions for older Windows
1385
- handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, False, pid)
1386
-
1387
- if not handle: # Check for None or 0
1388
- return False # Process doesn't exist or no permission at all
1389
-
1390
- # Check if process is still running (not just if handle exists)
1391
- import ctypes.wintypes
1392
- exit_code = ctypes.wintypes.DWORD()
1393
- STILL_ACTIVE = 259
1394
-
1395
- if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
1396
- kernel32.CloseHandle(handle)
1397
- is_still_active = exit_code.value == STILL_ACTIVE
1398
- return is_still_active
1399
-
1400
- kernel32.CloseHandle(handle)
1401
- return False # Couldn't get exit code
1402
- except Exception:
1403
- return False
1404
- else:
1405
- # Unix: Use os.kill with signal 0
1406
- try:
1407
- os.kill(pid, 0)
1408
- return True
1409
- except ProcessLookupError:
1410
- return False
1411
- except Exception:
1412
- return False
1413
-
1414
1673
  class LogParseResult(NamedTuple):
1415
1674
  """Result from parsing log messages"""
1416
1675
  messages: list[dict[str, str]]
@@ -1465,7 +1724,7 @@ def get_unread_messages(instance_name: str, update_position: bool = False) -> li
1465
1724
  instance_name: Name of instance to get messages for
1466
1725
  update_position: If True, mark messages as read by updating position
1467
1726
  """
1468
- log_file = hcom_path(LOG_FILE, ensure_parent=True)
1727
+ log_file = hcom_path(LOG_FILE)
1469
1728
 
1470
1729
  if not log_file.exists():
1471
1730
  return []
@@ -1512,10 +1771,6 @@ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
1512
1771
  # Returns: (display_category, formatted_age, status_description)
1513
1772
  now = int(time.time())
1514
1773
 
1515
- # Check if killed
1516
- if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
1517
- return "inactive", "", "killed"
1518
-
1519
1774
  # Get last known status
1520
1775
  last_status = pos_data.get('last_status', '')
1521
1776
  last_status_time = pos_data.get('last_status_time', 0)
@@ -1529,10 +1784,25 @@ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
1529
1784
 
1530
1785
  # Check timeout
1531
1786
  age = now - last_status_time
1532
- timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1787
+ timeout = pos_data.get('wait_timeout', get_config().timeout)
1533
1788
  if age > timeout:
1534
1789
  return "inactive", "", "timeout"
1535
1790
 
1791
+ # Check Stop hook heartbeat for both blocked-generic and waiting-stale detection
1792
+ last_stop = pos_data.get('last_stop', 0)
1793
+ heartbeat_age = now - last_stop if last_stop else 999999
1794
+
1795
+ # Generic "Claude is waiting for your input" from Notification hook is meaningless
1796
+ # If Stop hook is actively polling (heartbeat < 2s), instance is actually idle
1797
+ if last_status == 'blocked' and last_context == "Claude is waiting for your input" and heartbeat_age < 2:
1798
+ last_status = 'waiting'
1799
+ display_status, desc_template = 'waiting', 'idle'
1800
+
1801
+ # Detect stale 'waiting' status - check heartbeat, not status timestamp
1802
+ if last_status == 'waiting' and heartbeat_age > 2:
1803
+ status_suffix = " (bg)" if pos_data.get('background') else ""
1804
+ return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
1805
+
1536
1806
  # Format description with context if template has {}
1537
1807
  if '{}' in desc_template and last_context:
1538
1808
  status_desc = desc_template.format(last_context)
@@ -1548,15 +1818,12 @@ def get_status_block(status_type: str) -> str:
1548
1818
  text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
1549
1819
  return f"{text_color}{BOLD}{color} {symbol} {RESET}"
1550
1820
 
1551
- def format_message_line(msg, truncate=False):
1821
+ def format_message_line(msg: dict[str, str], truncate: bool = False) -> str:
1552
1822
  """Format a message for display"""
1553
1823
  time_obj = datetime.fromisoformat(msg['timestamp'])
1554
1824
  time_str = time_obj.strftime("%H:%M")
1555
-
1556
- sender_name = get_config_value('sender_name', 'bigboss')
1557
- sender_emoji = get_config_value('sender_emoji', '🐳')
1558
-
1559
- display_name = f"{sender_emoji} {msg['from']}" if msg['from'] == sender_name else msg['from']
1825
+
1826
+ display_name = f"{SENDER_EMOJI} {msg['from']}" if msg['from'] == SENDER else msg['from']
1560
1827
 
1561
1828
  if truncate:
1562
1829
  sender = display_name[:10]
@@ -1565,7 +1832,7 @@ def format_message_line(msg, truncate=False):
1565
1832
  else:
1566
1833
  return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
1567
1834
 
1568
- def show_recent_messages(messages, limit=None, truncate=False):
1835
+ def show_recent_messages(messages: list[dict[str, str]], limit: int | None = None, truncate: bool = False) -> None:
1569
1836
  """Show recent messages"""
1570
1837
  if limit is None:
1571
1838
  messages_to_show = messages
@@ -1577,14 +1844,14 @@ def show_recent_messages(messages, limit=None, truncate=False):
1577
1844
  print(format_message_line(msg, truncate))
1578
1845
 
1579
1846
 
1580
- def get_terminal_height():
1847
+ def get_terminal_height() -> int:
1581
1848
  """Get current terminal height"""
1582
1849
  try:
1583
1850
  return shutil.get_terminal_size().lines
1584
1851
  except (AttributeError, OSError):
1585
1852
  return 24
1586
1853
 
1587
- def show_recent_activity_alt_screen(limit=None):
1854
+ def show_recent_activity_alt_screen(limit: int | None = None) -> None:
1588
1855
  """Show recent messages in alt screen format with dynamic height"""
1589
1856
  if limit is None:
1590
1857
  # Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
@@ -1596,21 +1863,36 @@ def show_recent_activity_alt_screen(limit=None):
1596
1863
  messages = parse_log_messages(log_file).messages
1597
1864
  show_recent_messages(messages, limit, truncate=True)
1598
1865
 
1599
- def show_instances_by_directory():
1866
+ def should_show_in_watch(d: dict[str, Any]) -> bool:
1867
+ """Show only enabled instances by default"""
1868
+ # Hide disabled instances
1869
+ if not d.get('enabled', False):
1870
+ return False
1871
+
1872
+ # Hide truly ended sessions
1873
+ if d.get('session_ended'):
1874
+ return False
1875
+
1876
+ # Show all other instances (including 'closed' during transition)
1877
+ return True
1878
+
1879
+ def show_instances_by_directory() -> None:
1600
1880
  """Show instances organized by their working directories"""
1601
1881
  positions = load_all_positions()
1602
1882
  if not positions:
1603
1883
  print(f" {DIM}No Claude instances connected{RESET}")
1604
1884
  return
1605
-
1885
+
1606
1886
  if positions:
1607
1887
  directories = {}
1608
1888
  for instance_name, pos_data in positions.items():
1889
+ if not should_show_in_watch(pos_data):
1890
+ continue
1609
1891
  directory = pos_data.get("directory", "unknown")
1610
1892
  if directory not in directories:
1611
1893
  directories[directory] = []
1612
1894
  directories[directory].append((instance_name, pos_data))
1613
-
1895
+
1614
1896
  for directory, instances in directories.items():
1615
1897
  print(f" {directory}")
1616
1898
  for instance_name, pos_data in instances:
@@ -1622,7 +1904,7 @@ def show_instances_by_directory():
1622
1904
  else:
1623
1905
  print(f" {DIM}Error reading instance data{RESET}")
1624
1906
 
1625
- def alt_screen_detailed_status_and_input():
1907
+ def alt_screen_detailed_status_and_input() -> str:
1626
1908
  """Show detailed status in alt screen and get user input"""
1627
1909
  sys.stdout.write("\033[?1049h\033[2J\033[H")
1628
1910
 
@@ -1651,7 +1933,7 @@ def alt_screen_detailed_status_and_input():
1651
1933
 
1652
1934
  return message
1653
1935
 
1654
- def get_status_summary():
1936
+ def get_status_summary() -> str:
1655
1937
  """Get a one-line summary of all instance statuses"""
1656
1938
  positions = load_all_positions()
1657
1939
  if not positions:
@@ -1660,6 +1942,9 @@ def get_status_summary():
1660
1942
  status_counts = {status: 0 for status in STATUS_MAP.keys()}
1661
1943
 
1662
1944
  for _, pos_data in positions.items():
1945
+ # Only count instances that should be shown in watch
1946
+ if not should_show_in_watch(pos_data):
1947
+ continue
1663
1948
  status_type, _, _ = get_instance_status(pos_data)
1664
1949
  if status_type in status_counts:
1665
1950
  status_counts[status_type] += 1
@@ -1681,30 +1966,43 @@ def get_status_summary():
1681
1966
  else:
1682
1967
  return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1683
1968
 
1684
- def update_status(s):
1969
+ def update_status(s: str) -> None:
1685
1970
  """Update status line in place"""
1686
1971
  sys.stdout.write("\r\033[K" + s)
1687
1972
  sys.stdout.flush()
1688
1973
 
1689
- def log_line_with_status(message, status):
1974
+ def log_line_with_status(message: str, status: str) -> None:
1690
1975
  """Print message and immediately restore status"""
1691
1976
  sys.stdout.write("\r\033[K" + message + "\n")
1692
1977
  sys.stdout.write("\033[K" + status)
1693
1978
  sys.stdout.flush()
1694
1979
 
1695
- def initialize_instance_in_position_file(instance_name, session_id=None):
1980
+ def initialize_instance_in_position_file(instance_name: str, session_id: str | None = None) -> bool:
1696
1981
  """Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
1697
1982
  try:
1698
1983
  data = load_instance_position(instance_name)
1699
1984
 
1985
+ # Determine default enabled state: True for hcom-launched, False for vanilla
1986
+ is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
1987
+
1988
+ # Determine starting position: skip history or read from beginning (or last max_msgs num)
1989
+ initial_pos = 0
1990
+ if SKIP_HISTORY:
1991
+ log_file = hcom_path(LOG_FILE)
1992
+ if log_file.exists():
1993
+ initial_pos = log_file.stat().st_size
1994
+
1700
1995
  defaults = {
1701
- "pos": 0,
1996
+ "pos": initial_pos,
1997
+ "starting_pos": initial_pos,
1998
+ "enabled": is_hcom_launched,
1702
1999
  "directory": str(Path.cwd()),
1703
2000
  "last_stop": 0,
1704
2001
  "session_id": session_id or "",
1705
2002
  "transcript_path": "",
1706
2003
  "notification_message": "",
1707
- "alias_announced": False
2004
+ "alias_announced": False,
2005
+ "tag": None
1708
2006
  }
1709
2007
 
1710
2008
  # Add missing fields (preserve existing)
@@ -1715,7 +2013,7 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
1715
2013
  except Exception:
1716
2014
  return False
1717
2015
 
1718
- def update_instance_position(instance_name, update_fields):
2016
+ def update_instance_position(instance_name: str, update_fields: dict[str, Any]) -> None:
1719
2017
  """Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
1720
2018
  try:
1721
2019
  data = load_instance_position(instance_name)
@@ -1734,6 +2032,25 @@ def update_instance_position(instance_name, update_fields):
1734
2032
  else:
1735
2033
  raise
1736
2034
 
2035
+ def enable_instance(instance_name: str) -> None:
2036
+ """Enable instance - clears all stop flags and enables Stop hook polling"""
2037
+ update_instance_position(instance_name, {
2038
+ 'enabled': True,
2039
+ 'force_closed': False,
2040
+ 'session_ended': False
2041
+ })
2042
+ set_status(instance_name, 'started')
2043
+
2044
+ def disable_instance(instance_name: str, force: bool = False) -> None:
2045
+ """Disable instance - stops Stop hook polling"""
2046
+ updates = {
2047
+ 'enabled': False
2048
+ }
2049
+ if force:
2050
+ updates['force_closed'] = True
2051
+ update_instance_position(instance_name, updates)
2052
+ set_status(instance_name, 'force_stopped' if force else 'stopped')
2053
+
1737
2054
  def set_status(instance_name: str, status: str, context: str = ''):
1738
2055
  """Set instance status event with timestamp"""
1739
2056
  update_instance_position(instance_name, {
@@ -1741,98 +2058,14 @@ def set_status(instance_name: str, status: str, context: str = ''):
1741
2058
  'last_status_time': int(time.time()),
1742
2059
  'last_status_context': context
1743
2060
  })
1744
- log_hook_error(f"Set status for {instance_name} to {status} with context {context}") #TODO: change to 'log'?
2061
+ log_hook_error('set_status', f'Setting status to {status} with context {context} for {instance_name}')
1745
2062
 
1746
- def merge_instance_data(to_data, from_data):
1747
- """Merge instance data from from_data into to_data."""
1748
- # Use current session_id from source (overwrites previous)
1749
- to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
2063
+ # ==================== Command Functions ====================
1750
2064
 
1751
- # Update transient fields from source
1752
- to_data['pid'] = os.getppid() # Always use current PID
1753
- to_data['transcript_path'] = from_data.get('transcript_path', to_data.get('transcript_path', ''))
1754
-
1755
- # Preserve maximum position
1756
- to_data['pos'] = max(to_data.get('pos', 0), from_data.get('pos', 0))
1757
-
1758
- # Update directory to most recent
1759
- to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
1760
-
1761
- # Update heartbeat timestamp to most recent
1762
- to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
1763
-
1764
- # Merge new status fields - take most recent status event
1765
- from_time = from_data.get('last_status_time', 0)
1766
- to_time = to_data.get('last_status_time', 0)
1767
- if from_time > to_time:
1768
- to_data['last_status'] = from_data.get('last_status', '')
1769
- to_data['last_status_time'] = from_time
1770
- to_data['last_status_context'] = from_data.get('last_status_context', '')
1771
-
1772
- # Preserve background mode if set
1773
- to_data['background'] = to_data.get('background') or from_data.get('background')
1774
- if from_data.get('background_log_file'):
1775
- to_data['background_log_file'] = from_data['background_log_file']
1776
-
1777
- return to_data
1778
-
1779
- def terminate_process(pid, force=False):
1780
- """Cross-platform process termination"""
1781
- try:
1782
- if IS_WINDOWS:
1783
- cmd = ['taskkill', '/PID', str(pid)]
1784
- if force:
1785
- cmd.insert(1, '/F')
1786
- subprocess.run(cmd, capture_output=True, check=True)
1787
- else:
1788
- os.kill(pid, 9 if force else 15) # SIGKILL or SIGTERM
1789
- return True
1790
- except (ProcessLookupError, OSError, subprocess.CalledProcessError):
1791
- return False # Process already dead
1792
-
1793
- def merge_instance_immediately(from_name, to_name):
1794
- """Merge from_name into to_name with safety checks. Returns success message or error message."""
1795
- if from_name == to_name:
1796
- return ""
1797
-
1798
- try:
1799
- from_data = load_instance_position(from_name)
1800
- to_data = load_instance_position(to_name)
1801
-
1802
- # Check if target has recent activity (time-based check instead of PID)
1803
- now = time.time()
1804
- last_activity = max(
1805
- to_data.get('last_stop', 0),
1806
- to_data.get('last_status_time', 0)
1807
- )
1808
- time_since_activity = now - last_activity
1809
- if time_since_activity < MERGE_ACTIVITY_THRESHOLD:
1810
- return f"Cannot recover {to_name}: instance is active (activity {int(time_since_activity)}s ago)"
1811
-
1812
- # Merge data using helper
1813
- to_data = merge_instance_data(to_data, from_data)
1814
-
1815
- # Save merged data - check for success
1816
- if not save_instance_position(to_name, to_data):
1817
- return f"Failed to save merged data for {to_name}"
1818
-
1819
- # Cleanup source file only after successful save
1820
- try:
1821
- hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
1822
- except (FileNotFoundError, PermissionError, OSError):
1823
- pass # Non-critical if cleanup fails
1824
-
1825
- return f"[SUCCESS] ✓ Recovered alias: {to_name}"
1826
- except Exception:
1827
- return f"Failed to recover alias: {to_name}"
1828
-
1829
-
1830
- # ==================== Command Functions ====================
1831
-
1832
- def show_main_screen_header():
2065
+ def show_main_screen_header() -> list[dict[str, str]]:
1833
2066
  """Show header for main screen"""
1834
2067
  sys.stdout.write("\033[2J\033[H")
1835
-
2068
+
1836
2069
  log_file = hcom_path(LOG_FILE)
1837
2070
  all_messages = []
1838
2071
  if log_file.exists():
@@ -1843,42 +2076,9 @@ def show_main_screen_header():
1843
2076
 
1844
2077
  return all_messages
1845
2078
 
1846
- def show_cli_hints(to_stderr=True):
1847
- """Show CLI hints if configured"""
1848
- cli_hints = get_config_value('cli_hints', '')
1849
- if cli_hints:
1850
- if to_stderr:
1851
- print(f"\n{cli_hints}", file=sys.stderr)
1852
- else:
1853
- print(f"\n{cli_hints}")
1854
-
1855
- def cmd_help():
2079
+ def cmd_help() -> int:
1856
2080
  """Show help text"""
1857
- # Basic help for interactive users
1858
- print("""hcom - Claude Hook Comms
1859
-
1860
- Usage:
1861
- hcom open [n] Launch n Claude instances
1862
- hcom open <agent> Launch named agent from .claude/agents/
1863
- hcom open --prefix <team> n Launch n instances with team prefix
1864
- hcom open --background Launch instances as background processes (-p also works)
1865
- hcom open --claude-args "--model sonnet" Pass claude code CLI flags
1866
- hcom watch View conversation dashboard
1867
- hcom clear Clear and archive conversation
1868
- hcom cleanup Remove hooks from current directory
1869
- hcom cleanup --all Remove hooks from all tracked directories
1870
- hcom kill [instance alias] Kill specific instance
1871
- hcom kill --all Kill all running instances
1872
- hcom help Show this help
1873
-
1874
- Automation:
1875
- hcom send 'msg' Send message to all
1876
- hcom send '@prefix msg' Send to specific instances
1877
- hcom watch --logs Show conversation log
1878
- hcom watch --status Show status of instances
1879
- hcom watch --wait [seconds] Wait for new messages (default 60s)
1880
-
1881
- Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
2081
+ print(HELP_TEXT)
1882
2082
 
1883
2083
  # Additional help for AI assistants
1884
2084
  if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
@@ -1888,26 +2088,26 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
1888
2088
 
1889
2089
  CONCEPT: HCOM launches Claude Code instances in new terminal windows.
1890
2090
  They communicate with each other via a shared conversation.
1891
- You communicate with them via hcom automation commands.
2091
+ You communicate with them via hcom commands.
1892
2092
 
1893
2093
  KEY UNDERSTANDING:
1894
2094
  • Single conversation - All instances share ~/.hcom/hcom.log
1895
- • Messaging - Use 'hcom send "message"' from CLI to send messages to instances
1896
- • Instances receive messages via hooks automatically and send with 'eval $HCOM send "message"'
2095
+ • Messaging - CLI and instances send with hcom send "message"
2096
+ • Instances receive messages via hooks automatically
1897
2097
  • hcom open is directory-specific - always cd to project directory first
1898
- hcom watch --wait outputs existing logs, then waits for the next message, prints it, and exits.
1899
- Times out after [seconds]
1900
- Named agents are custom system prompts created by users/claude code.
1901
- "reviewer" named agent loads .claude/agents/reviewer.md (if it was ever created)
2098
+ Named agents are custom system prompt files created by users/claude code beforehand.
2099
+ Named agents load from .claude/agents/<name>.md - if they have been created
2100
+ hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
1902
2101
 
1903
2102
  LAUNCH PATTERNS:
1904
- hcom open 2 reviewer # 2 generic + 1 reviewer agent
1905
- hcom open reviewer reviewer # 2 separate reviewer instances
1906
- hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
1907
- hcom open --claude-args "--model sonnet" # Pass 'claude' CLI flags
1908
- hcom open --background (or -p) then hcom kill # Detached background process
1909
- hcom watch --status (get sessionid) then hcom open --claude-args "--resume <sessionid>"
1910
- HCOM_INITIAL_PROMPT="do x task" hcom open # initial prompt to instance
2103
+ hcom 2 claude # 2 generic instances
2104
+ hcom claude --model sonnet # 1 instance with sonnet model
2105
+ hcom 3 claude -p "task" # 3 instances in background with prompt
2106
+ HCOM_AGENT=reviewer hcom 3 claude # 3 reviewer instances (agent file must exist)
2107
+ HCOM_TAG=api hcom 2 claude # Team naming: api-hova7, api-kolec
2108
+ HCOM_AGENT=reviewer,tester hcom 2 claude # 2 reviewers + 2 testers
2109
+ hcom claude --resume <sessionid> # Resume specific session
2110
+ HCOM_PROMPT="task" hcom claude # Set initial prompt for instance
1911
2111
 
1912
2112
  @MENTION TARGETING:
1913
2113
  hcom send "message" # Broadcasts to everyone
@@ -1916,144 +2116,161 @@ LAUNCH PATTERNS:
1916
2116
  (Unmatched @mentions broadcast to everyone)
1917
2117
 
1918
2118
  STATUS INDICATORS:
1919
- • ▶ active - instance is working (processing/executing)
1920
- delivered - instance just received a message
1921
- waiting - instance is waiting for new messages
1922
- • ■ blocked - instance is blocked by permission request (needs user approval)
1923
- • ○ inactive - instance is timed out, disconnected, etc
1924
- • ○ unknown - no status information available
2119
+ • ▶ active - processing/executing • ▷ delivered - instance just received a message
2120
+ idle - waiting for new messages • ■ blocked - permission request (needs user approval)
2121
+ inactive - timed out, disconnected, etc ○ unknown
1925
2122
 
1926
2123
  CONFIG:
1927
- Config file (persistent): ~/.hcom/config.json
2124
+ Config file: ~/.hcom/config.env (KEY=VALUE format)
1928
2125
 
1929
- Key settings (full list in config.json):
1930
- terminal_mode: "new_window" (default) | "same_terminal" | "show_commands"
1931
- initial_prompt: "Say hi in chat", first_use_text: "Essential messages only..."
1932
- instance_hints: "text", cli_hints: "text" # Extra info for instances/CLI
1933
- env_overrides: "custom environment variables for instances"
2126
+ Environment variables (override config file):
2127
+ HCOM_TERMINAL="new" (default) | "here" | "print" | "kitty -e {script}" (custom)
2128
+ HCOM_PROMPT="say hi in hcom chat"
2129
+ HCOM_HINTS="text" # Extra info appended to all messages sent to instances
2130
+ HCOM_TAG="api" # Group instances under api-* names
2131
+ HCOM_AGENT="reviewer" # Launch with agent (comma-separated for multiple)
1934
2132
 
1935
- Temporary environment overrides for any setting (all caps & append HCOM_):
1936
- HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages received by instance
1937
- export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
1938
2133
 
1939
2134
  EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
1940
2135
  with 'hcom watch --status'. Instances respond automatically in shared chat.
1941
2136
 
1942
2137
  Run 'claude --help' to see all claude code CLI flags.""")
1943
2138
 
1944
- show_cli_hints(to_stderr=False)
1945
2139
  else:
1946
2140
  if not IS_WINDOWS:
1947
2141
  print("\nFor additional info & examples: hcom --help | cat")
1948
2142
 
1949
2143
  return 0
1950
2144
 
1951
- def cmd_open(*args):
1952
- """Launch Claude instances with chat enabled"""
2145
+ def cmd_launch(argv: list[str]) -> int:
2146
+ """Launch Claude instances: hcom [N] [claude] [args]"""
1953
2147
  try:
1954
- # Parse arguments
1955
- instances, prefix, claude_args, background = parse_open_args(list(args))
2148
+ # Parse arguments: hcom [N] [claude] [args]
2149
+ count = 1
2150
+ forwarded = []
2151
+
2152
+ # Extract count if first arg is digit
2153
+ if argv and argv[0].isdigit():
2154
+ count = int(argv[0])
2155
+ if count <= 0:
2156
+ raise CLIError('Count must be positive.')
2157
+ if count > 100:
2158
+ raise CLIError('Too many instances requested (max 100).')
2159
+ argv = argv[1:]
2160
+
2161
+ # Skip 'claude' keyword if present
2162
+ if argv and argv[0] == 'claude':
2163
+ argv = argv[1:]
2164
+
2165
+ # Forward all remaining args to claude CLI
2166
+ forwarded = argv
2167
+
2168
+ # Get tag from config
2169
+ tag = get_config().tag
2170
+ if tag and '|' in tag:
2171
+ raise CLIError('Tag cannot contain "|" characters.')
2172
+
2173
+ # Get agents from config (comma-separated)
2174
+ agent_env = get_config().agent
2175
+ agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['generic']
2176
+
2177
+ # Detect background mode from -p/--print flags in forwarded args
2178
+ background = '-p' in forwarded or '--print' in forwarded
1956
2179
 
1957
2180
  # Add -p flag and stream-json output for background mode if not already present
2181
+ claude_args = forwarded
1958
2182
  if background and '-p' not in claude_args and '--print' not in claude_args:
1959
2183
  claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
1960
-
1961
- terminal_mode = get_config_value('terminal_mode', 'new_window')
1962
-
1963
- # Fail fast for same_terminal with multiple instances
1964
- if terminal_mode == 'same_terminal' and len(instances) > 1:
2184
+
2185
+ terminal_mode = get_config().terminal
2186
+
2187
+ # Calculate total instances to launch
2188
+ total_instances = count * len(agents)
2189
+
2190
+ # Fail fast for here mode with multiple instances
2191
+ if terminal_mode == 'here' and total_instances > 1:
1965
2192
  print(format_error(
1966
- f"same_terminal mode cannot launch {len(instances)} instances",
1967
- "Use 'hcom open' for one generic instance or 'hcom open <agent>' for one agent"
2193
+ f"'here' mode cannot launch {total_instances} instances (it's one terminal window)",
2194
+ "Use 'hcom 1' for one generic instance"
1968
2195
  ), file=sys.stderr)
1969
2196
  return 1
1970
-
1971
- try:
1972
- setup_hooks()
1973
- except Exception as e:
1974
- print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
1975
- return 1
1976
-
1977
- log_file = hcom_path(LOG_FILE, ensure_parent=True)
2197
+
2198
+ log_file = hcom_path(LOG_FILE)
1978
2199
  instances_dir = hcom_path(INSTANCES_DIR)
1979
- instances_dir.mkdir(exist_ok=True)
1980
-
2200
+
1981
2201
  if not log_file.exists():
1982
2202
  log_file.touch()
1983
-
2203
+
1984
2204
  # Build environment variables for Claude instances
1985
2205
  base_env = build_claude_env()
1986
2206
 
1987
- # Add prefix-specific hints if provided
1988
- if prefix:
1989
- base_env['HCOM_PREFIX'] = prefix
1990
- send_cmd = build_send_command()
1991
- hint = f"To respond to {prefix} group: {send_cmd} '@{prefix} message'"
1992
- base_env['HCOM_INSTANCE_HINTS'] = hint
1993
- first_use = f"You're in the {prefix} group. Use {prefix} to message: {send_cmd} '@{prefix} message'"
1994
- base_env['HCOM_FIRST_USE_TEXT'] = first_use
1995
-
2207
+ # Add tag-specific hints if provided
2208
+ if tag:
2209
+ base_env['HCOM_TAG'] = tag
2210
+
1996
2211
  launched = 0
1997
- initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
1998
-
1999
- for _, instance_type in enumerate(instances):
2000
- instance_env = base_env.copy()
2001
-
2002
- # Set unique launch ID for sender detection in cmd_send()
2003
- launch_id = f"{int(time.time())}_{random.randint(10000, 99999)}"
2004
- instance_env['HCOM_LAUNCH_ID'] = launch_id
2005
-
2006
- # Mark background instances via environment with log filename
2007
- if background:
2008
- # Generate unique log filename
2009
- log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
2010
- instance_env['HCOM_BACKGROUND'] = log_filename
2011
-
2012
- # Build claude command
2013
- if instance_type == 'generic':
2014
- # Generic instance - no agent content
2015
- claude_cmd, _ = build_claude_command(
2016
- agent_content=None,
2017
- claude_args=claude_args,
2018
- initial_prompt=initial_prompt
2019
- )
2020
- else:
2021
- # Agent instance
2022
- try:
2023
- agent_content, agent_config = resolve_agent(instance_type)
2024
- # Mark this as a subagent instance for SessionStart hook
2025
- instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
2026
- # Prepend agent instance awareness to system prompt
2027
- agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
2028
- agent_content = agent_prefix + agent_content
2029
- # Use agent's model and tools if specified and not overridden in claude_args
2030
- agent_model = agent_config.get('model')
2031
- agent_tools = agent_config.get('tools')
2212
+ initial_prompt = get_config().prompt
2213
+
2214
+ # Launch count instances of each agent
2215
+ for agent in agents:
2216
+ for _ in range(count):
2217
+ instance_type = agent
2218
+ instance_env = base_env.copy()
2219
+
2220
+ # Mark all hcom-launched instances
2221
+ instance_env['HCOM_LAUNCHED'] = '1'
2222
+
2223
+ # Mark background instances via environment with log filename
2224
+ if background:
2225
+ # Generate unique log filename
2226
+ log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
2227
+ instance_env['HCOM_BACKGROUND'] = log_filename
2228
+
2229
+ # Build claude command
2230
+ if instance_type == 'generic':
2231
+ # Generic instance - no agent content
2032
2232
  claude_cmd, _ = build_claude_command(
2033
- agent_content=agent_content,
2233
+ agent_content=None,
2034
2234
  claude_args=claude_args,
2035
- initial_prompt=initial_prompt,
2036
- model=agent_model,
2037
- tools=agent_tools
2235
+ initial_prompt=initial_prompt
2038
2236
  )
2039
- # Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
2040
- except (FileNotFoundError, ValueError) as e:
2041
- print(str(e), file=sys.stderr)
2042
- continue
2043
-
2044
- try:
2045
- if background:
2046
- log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
2047
- if log_file:
2048
- print(f"Background instance launched, log: {log_file}")
2049
- launched += 1
2050
2237
  else:
2051
- if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
2052
- launched += 1
2053
- except Exception as e:
2054
- print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
2055
-
2056
- requested = len(instances)
2238
+ # Agent instance
2239
+ try:
2240
+ agent_content, agent_config = resolve_agent(instance_type)
2241
+ # Mark this as a subagent instance for SessionStart hook
2242
+ instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
2243
+ # Prepend agent instance awareness to system prompt
2244
+ agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
2245
+ agent_content = agent_prefix + agent_content
2246
+ # Use agent's model and tools if specified and not overridden in claude_args
2247
+ agent_model = agent_config.get('model')
2248
+ agent_tools = agent_config.get('tools')
2249
+ claude_cmd, _ = build_claude_command(
2250
+ agent_content=agent_content,
2251
+ claude_args=claude_args,
2252
+ initial_prompt=initial_prompt,
2253
+ model=agent_model,
2254
+ tools=agent_tools
2255
+ )
2256
+ # Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
2257
+ except (FileNotFoundError, ValueError) as e:
2258
+ print(str(e), file=sys.stderr)
2259
+ continue
2260
+
2261
+ try:
2262
+ if background:
2263
+ log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
2264
+ if log_file:
2265
+ print(f"Background instance launched, log: {log_file}")
2266
+ launched += 1
2267
+ else:
2268
+ if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
2269
+ launched += 1
2270
+ except Exception as e:
2271
+ print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
2272
+
2273
+ requested = total_instances
2057
2274
  failed = requested - launched
2058
2275
 
2059
2276
  if launched == 0:
@@ -2066,36 +2283,31 @@ def cmd_open(*args):
2066
2283
  else:
2067
2284
  print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
2068
2285
 
2069
- # Auto-launch watch dashboard if configured and conditions are met
2070
- terminal_mode = get_config_value('terminal_mode')
2071
- auto_watch = get_config_value('auto_watch', True)
2286
+ # Auto-launch watch dashboard if in new window mode (new or custom) and all instances launched successfully
2287
+ terminal_mode = get_config().terminal
2072
2288
 
2073
- # Only auto-watch if ALL instances launched successfully
2074
- if terminal_mode == 'new_window' and auto_watch and failed == 0 and is_interactive():
2289
+ # Only auto-watch if ALL instances launched successfully and launches windows (not 'here' or 'print')
2290
+ if terminal_mode not in ('here', 'print') and failed == 0 and is_interactive():
2075
2291
  # Show tips first if needed
2076
- if prefix:
2077
- print(f"\n • Send to {prefix} team: hcom send '@{prefix} message'")
2292
+ if tag:
2293
+ print(f"\n • Send to {tag} team: hcom send '@{tag} message'")
2078
2294
 
2079
2295
  # Clear transition message
2080
2296
  print("\nOpening hcom watch...")
2081
2297
  time.sleep(2) # Brief pause so user sees the message
2082
2298
 
2083
2299
  # Launch interactive watch dashboard in current terminal
2084
- return cmd_watch()
2300
+ return cmd_watch([]) # Empty argv = interactive mode
2085
2301
  else:
2086
2302
  tips = [
2087
2303
  "Run 'hcom watch' to view/send in conversation dashboard",
2088
2304
  ]
2089
- if prefix:
2090
- tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
2305
+ if tag:
2306
+ tips.append(f"Send to {tag} team: hcom send '@{tag} message'")
2091
2307
 
2092
2308
  if tips:
2093
2309
  print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
2094
2310
 
2095
- # Show cli_hints if configured (non-interactive mode)
2096
- if not is_interactive():
2097
- show_cli_hints(to_stderr=False)
2098
-
2099
2311
  return 0
2100
2312
 
2101
2313
  except ValueError as e:
@@ -2105,40 +2317,45 @@ def cmd_open(*args):
2105
2317
  print(str(e), file=sys.stderr)
2106
2318
  return 1
2107
2319
 
2108
- def cmd_watch(*args):
2109
- """View conversation dashboard"""
2320
+ def cmd_watch(argv: list[str]) -> int:
2321
+ """View conversation dashboard: hcom watch [--logs|--status|--wait [SEC]]"""
2322
+ # Extract launch flag for external terminals (used by claude code bootstrap)
2323
+ cleaned_args: list[str] = []
2324
+ for arg in argv:
2325
+ if arg == '--launch':
2326
+ watch_cmd = f"{build_hcom_command()} watch"
2327
+ result = launch_terminal(watch_cmd, build_claude_env(), cwd=os.getcwd())
2328
+ return 0 if result else 1
2329
+ else:
2330
+ cleaned_args.append(arg)
2331
+ argv = cleaned_args
2332
+
2333
+ # Parse arguments
2334
+ show_logs = '--logs' in argv
2335
+ show_status = '--status' in argv
2336
+ wait_timeout = None
2337
+
2338
+ # Check for --wait flag
2339
+ if '--wait' in argv:
2340
+ idx = argv.index('--wait')
2341
+ if idx + 1 < len(argv):
2342
+ try:
2343
+ wait_timeout = int(argv[idx + 1])
2344
+ if wait_timeout < 0:
2345
+ raise CLIError('--wait expects a non-negative number of seconds.')
2346
+ except ValueError:
2347
+ wait_timeout = 60 # Default for non-numeric values
2348
+ else:
2349
+ wait_timeout = 60 # Default timeout
2350
+ show_logs = True # --wait implies logs mode
2351
+
2110
2352
  log_file = hcom_path(LOG_FILE)
2111
2353
  instances_dir = hcom_path(INSTANCES_DIR)
2112
-
2354
+
2113
2355
  if not log_file.exists() and not instances_dir.exists():
2114
- print(format_error("No conversation log found", "Run 'hcom open' first"), file=sys.stderr)
2356
+ print(format_error("No conversation log found", "Run 'hcom' first"), file=sys.stderr)
2115
2357
  return 1
2116
-
2117
- # Parse arguments
2118
- show_logs = False
2119
- show_status = False
2120
- wait_timeout = None
2121
-
2122
- i = 0
2123
- while i < len(args):
2124
- arg = args[i]
2125
- if arg == '--logs':
2126
- show_logs = True
2127
- elif arg == '--status':
2128
- show_status = True
2129
- elif arg == '--wait':
2130
- # Check if next arg is a number
2131
- if i + 1 < len(args) and args[i + 1].isdigit():
2132
- wait_timeout = int(args[i + 1])
2133
- i += 1 # Skip the number
2134
- else:
2135
- wait_timeout = 60 # Default
2136
- i += 1
2137
-
2138
- # If wait is specified, enable logs to show the messages
2139
- if wait_timeout is not None:
2140
- show_logs = True
2141
-
2358
+
2142
2359
  # Non-interactive mode (no TTY or flags specified)
2143
2360
  if not is_interactive() or show_logs or show_status:
2144
2361
  if show_logs:
@@ -2150,14 +2367,16 @@ def cmd_watch(*args):
2150
2367
  last_pos = 0
2151
2368
  messages = []
2152
2369
 
2153
- # If --wait, show only recent messages to prevent context bloat
2370
+ # If --wait, show recent messages (max of: last 3 messages OR all messages in last 5 seconds)
2154
2371
  if wait_timeout is not None:
2155
2372
  cutoff = datetime.now() - timedelta(seconds=5)
2156
- recent_messages = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
2157
-
2373
+ recent_by_time = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
2374
+ last_three = messages[-3:] if len(messages) >= 3 else messages
2375
+ # Show whichever is larger: recent by time or last 3
2376
+ recent_messages = recent_by_time if len(recent_by_time) > len(last_three) else last_three
2158
2377
  # Status to stderr, data to stdout
2159
2378
  if recent_messages:
2160
- print(f'---Showing last 5 seconds of messages---', file=sys.stderr) #TODO: change this to recent messages and have logic like last 3 messages + all messages in last 5 seconds.
2379
+ print(f'---Showing recent messages---', file=sys.stderr)
2161
2380
  for msg in recent_messages:
2162
2381
  print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
2163
2382
  print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
@@ -2195,7 +2414,6 @@ def cmd_watch(*args):
2195
2414
  else:
2196
2415
  print("No messages yet", file=sys.stderr)
2197
2416
 
2198
- show_cli_hints()
2199
2417
 
2200
2418
  elif show_status:
2201
2419
  # Build JSON output
@@ -2205,6 +2423,8 @@ def cmd_watch(*args):
2205
2423
  status_counts = {}
2206
2424
 
2207
2425
  for name, data in positions.items():
2426
+ if not should_show_in_watch(data):
2427
+ continue
2208
2428
  status, age, _ = get_instance_status(data)
2209
2429
  instances[name] = {
2210
2430
  "status": status,
@@ -2214,7 +2434,6 @@ def cmd_watch(*args):
2214
2434
  "last_status": data.get("last_status", ""),
2215
2435
  "last_status_time": data.get("last_status_time", 0),
2216
2436
  "last_status_context": data.get("last_status_context", ""),
2217
- "pid": data.get("pid"),
2218
2437
  "background": bool(data.get("background"))
2219
2438
  }
2220
2439
  status_counts[status] = status_counts.get(status, 0) + 1
@@ -2235,16 +2454,14 @@ def cmd_watch(*args):
2235
2454
  }
2236
2455
 
2237
2456
  print(json.dumps(output, indent=2))
2238
- show_cli_hints()
2239
2457
  else:
2240
2458
  print("No TTY - Automation usage:", file=sys.stderr)
2241
- print(" hcom send 'message' Send message to chat", file=sys.stderr)
2242
2459
  print(" hcom watch --logs Show message history", file=sys.stderr)
2243
2460
  print(" hcom watch --status Show instance status", file=sys.stderr)
2244
2461
  print(" hcom watch --wait Wait for new messages", file=sys.stderr)
2462
+ print(" hcom watch --launch Launch interactive dashboard in new terminal", file=sys.stderr)
2245
2463
  print(" Full information: hcom --help")
2246
2464
 
2247
- show_cli_hints()
2248
2465
 
2249
2466
  return 0
2250
2467
 
@@ -2320,10 +2537,9 @@ def cmd_watch(*args):
2320
2537
  last_pos = log_file.stat().st_size
2321
2538
 
2322
2539
  if message and message.strip():
2323
- sender_name = get_config_value('sender_name', 'bigboss')
2324
- send_message(sender_name, message.strip())
2540
+ send_cli(message.strip(), quiet=True)
2325
2541
  print(f"{FG_GREEN}✓ Sent{RESET}")
2326
-
2542
+
2327
2543
  print()
2328
2544
 
2329
2545
  current_status = get_status_summary()
@@ -2337,37 +2553,24 @@ def cmd_watch(*args):
2337
2553
 
2338
2554
  return 0
2339
2555
 
2340
- def cmd_clear():
2556
+ def clear() -> int:
2341
2557
  """Clear and archive conversation"""
2342
- log_file = hcom_path(LOG_FILE, ensure_parent=True)
2558
+ log_file = hcom_path(LOG_FILE)
2343
2559
  instances_dir = hcom_path(INSTANCES_DIR)
2344
2560
  archive_folder = hcom_path(ARCHIVE_DIR)
2345
- archive_folder.mkdir(exist_ok=True)
2346
2561
 
2347
- # Clean up temp files from failed atomic writes
2562
+ # cleanup: temp files, old scripts, old outbox files
2563
+ cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
2348
2564
  if instances_dir.exists():
2349
- deleted_count = sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
2350
- if deleted_count > 0:
2351
- print(f"Cleaned up {deleted_count} temp files")
2565
+ sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
2352
2566
 
2353
- # Clean up old script files (older than 24 hours)
2354
2567
  scripts_dir = hcom_path(SCRIPTS_DIR)
2355
2568
  if scripts_dir.exists():
2356
- cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
2357
- script_count = sum(1 for f in scripts_dir.glob('*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
2358
- if script_count > 0:
2359
- print(f"Cleaned up {script_count} old script files")
2360
-
2361
- # Clean up old launch mapping files (older than 24 hours)
2362
- if instances_dir.exists():
2363
- cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
2364
- mapping_count = sum(1 for f in instances_dir.glob('.launch_map_*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
2365
- if mapping_count > 0:
2366
- print(f"Cleaned up {mapping_count} old launch mapping files")
2569
+ 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)
2367
2570
 
2368
2571
  # Check if hcom files exist
2369
2572
  if not log_file.exists() and not instances_dir.exists():
2370
- print("No hcom conversation to clear")
2573
+ print("No HCOM conversation to clear")
2371
2574
  return 0
2372
2575
 
2373
2576
  # Archive existing files if they have content
@@ -2381,7 +2584,7 @@ def cmd_clear():
2381
2584
  if has_log or has_instances:
2382
2585
  # Create session archive folder with timestamp
2383
2586
  session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
2384
- session_archive.mkdir(exist_ok=True)
2587
+ session_archive.mkdir(parents=True, exist_ok=True)
2385
2588
 
2386
2589
  # Archive log file
2387
2590
  if has_log:
@@ -2394,16 +2597,12 @@ def cmd_clear():
2394
2597
  # Archive instances
2395
2598
  if has_instances:
2396
2599
  archive_instances = session_archive / INSTANCES_DIR
2397
- archive_instances.mkdir(exist_ok=True)
2600
+ archive_instances.mkdir(parents=True, exist_ok=True)
2398
2601
 
2399
2602
  # Move json files only
2400
2603
  for f in instances_dir.glob('*.json'):
2401
2604
  f.rename(archive_instances / f.name)
2402
2605
 
2403
- # Clean up orphaned mapping files (position files are archived)
2404
- for f in instances_dir.glob('.launch_map_*'):
2405
- f.unlink(missing_ok=True)
2406
-
2407
2606
  archived = True
2408
2607
  else:
2409
2608
  # Clean up empty files/dirs
@@ -2417,14 +2616,33 @@ def cmd_clear():
2417
2616
 
2418
2617
  if archived:
2419
2618
  print(f"Archived to archive/session-{timestamp}/")
2420
- print("Started fresh hcom conversation log")
2619
+ print("Started fresh HCOM conversation log")
2421
2620
  return 0
2422
2621
 
2423
2622
  except Exception as e:
2424
2623
  print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
2425
2624
  return 1
2426
2625
 
2427
- def cleanup_directory_hooks(directory):
2626
+ def remove_global_hooks() -> bool:
2627
+ """Remove HCOM hooks from ~/.claude/settings.json
2628
+ Returns True on success, False on failure."""
2629
+ settings_path = get_claude_settings_path()
2630
+
2631
+ if not settings_path.exists():
2632
+ return True # No settings = no hooks to remove
2633
+
2634
+ try:
2635
+ settings = load_settings_json(settings_path, default=None)
2636
+ if not settings:
2637
+ return False
2638
+
2639
+ _remove_hcom_hooks_from_settings(settings)
2640
+ atomic_write(settings_path, json.dumps(settings, indent=2))
2641
+ return True
2642
+ except Exception:
2643
+ return False
2644
+
2645
+ def cleanup_directory_hooks(directory: Path | str) -> tuple[int, str]:
2428
2646
  """Remove hcom hooks from a specific directory
2429
2647
  Returns tuple: (exit_code, message)
2430
2648
  exit_code: 0 for success, 1 for error
@@ -2437,25 +2655,21 @@ def cleanup_directory_hooks(directory):
2437
2655
 
2438
2656
  try:
2439
2657
  # Load existing settings
2440
- settings = read_file_with_retry(
2441
- settings_path,
2442
- lambda f: json.load(f),
2443
- default=None
2444
- )
2658
+ settings = load_settings_json(settings_path, default=None)
2445
2659
  if not settings:
2446
2660
  return 1, "Cannot read Claude settings"
2447
2661
 
2448
2662
  hooks_found = False
2449
-
2663
+
2450
2664
  # Include PostToolUse for backward compatibility cleanup
2451
2665
  original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2452
- for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2666
+ for event in LEGACY_HOOK_TYPES)
2453
2667
 
2454
2668
  _remove_hcom_hooks_from_settings(settings)
2455
2669
 
2456
2670
  # Check if any were removed
2457
2671
  new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2458
- for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2672
+ for event in LEGACY_HOOK_TYPES)
2459
2673
  if new_hook_count < original_hook_count:
2460
2674
  hooks_found = True
2461
2675
 
@@ -2478,59 +2692,289 @@ def cleanup_directory_hooks(directory):
2478
2692
  return 1, format_error(f"Cannot modify settings.local.json: {e}")
2479
2693
 
2480
2694
 
2481
- def cmd_kill(*args):
2482
- """Kill instances by name or all with --all"""
2695
+ def cmd_stop(argv: list[str]) -> int:
2696
+ """Stop instances: hcom stop [alias|all] [--force] [--_hcom_session ID]"""
2697
+ # Parse arguments
2698
+ target = None
2699
+ force = '--force' in argv
2700
+ session_id = None
2701
+
2702
+ # Extract --_hcom_session if present
2703
+ if '--_hcom_session' in argv:
2704
+ idx = argv.index('--_hcom_session')
2705
+ if idx + 1 < len(argv):
2706
+ session_id = argv[idx + 1]
2707
+ argv = argv[:idx] + argv[idx + 2:]
2708
+
2709
+ # Remove flags to get target
2710
+ args_without_flags = [a for a in argv if not a.startswith('--')]
2711
+ if args_without_flags:
2712
+ target = args_without_flags[0]
2713
+
2714
+ # Handle 'all' target
2715
+ if target == 'all':
2716
+ positions = load_all_positions()
2717
+
2718
+ if not positions:
2719
+ print("No instances found")
2720
+ return 0
2483
2721
 
2484
- instance_name = args[0] if args and args[0] != '--all' else None
2485
- positions = load_all_positions() if not instance_name else {instance_name: load_instance_position(instance_name)}
2722
+ stopped_count = 0
2723
+ bg_logs = []
2724
+ stopped_names = []
2725
+ for instance_name, instance_data in positions.items():
2726
+ if instance_data.get('enabled', False):
2727
+ disable_instance(instance_name)
2728
+ stopped_names.append(instance_name)
2729
+ stopped_count += 1
2730
+
2731
+ # Track background logs
2732
+ if instance_data.get('background'):
2733
+ log_file = instance_data.get('background_log_file', '')
2734
+ if log_file:
2735
+ bg_logs.append((instance_name, log_file))
2486
2736
 
2487
- # Filter to instances with PIDs (any instance that's running)
2488
- targets = [(name, data) for name, data in positions.items() if data.get('pid')]
2737
+ if stopped_count == 0:
2738
+ print("No instances to stop")
2739
+ else:
2740
+ print(f"Stopped {stopped_count} instance(s): {', '.join(stopped_names)}")
2489
2741
 
2490
- if not targets:
2491
- print(f"No running process found for {instance_name}" if instance_name else "No running instances found")
2492
- return 1 if instance_name else 0
2742
+ # Show background logs if any
2743
+ if bg_logs:
2744
+ print()
2745
+ print("Background instance logs:")
2746
+ for name, log_file in bg_logs:
2747
+ print(f" {name}: {log_file}")
2493
2748
 
2494
- killed_count = 0
2495
- for target_name, target_data in targets:
2496
- status, age, _ = get_instance_status(target_data)
2497
- instance_type = "background" if target_data.get('background') else "foreground"
2749
+ return 0
2498
2750
 
2499
- pid = int(target_data['pid'])
2500
- try:
2501
- # Try graceful termination first
2502
- terminate_process(pid, force=False)
2503
-
2504
- # Wait for process to exit gracefully
2505
- for _ in range(20):
2506
- time.sleep(KILL_CHECK_INTERVAL)
2507
- if not is_process_alive(pid):
2508
- # Process terminated successfully
2509
- break
2751
+ # Stop specific instance or self
2752
+ # Get instance name from injected session or target
2753
+ if session_id and not target:
2754
+ instance_name, _ = resolve_instance_name(session_id, get_config().tag)
2755
+ else:
2756
+ instance_name = target
2757
+
2758
+ position = load_instance_position(instance_name) if instance_name else None
2759
+
2760
+ if not instance_name:
2761
+ if os.environ.get('CLAUDECODE') == '1':
2762
+ print("Error: Cannot determine instance", file=sys.stderr)
2763
+ print("Usage: Prompt Claude to run 'hcom stop' (or directly use: hcom stop <alias> or hcom stop all)", file=sys.stderr)
2764
+ else:
2765
+ print("Error: Alias required", file=sys.stderr)
2766
+ print("Usage: hcom stop <alias>", file=sys.stderr)
2767
+ print(" Or: hcom stop all", file=sys.stderr)
2768
+ print(" Or: prompt claude to run 'hcom stop' on itself", file=sys.stderr)
2769
+ positions = load_all_positions()
2770
+ visible = [alias for alias, data in positions.items() if should_show_in_watch(data)]
2771
+ if visible:
2772
+ print(f"Active aliases: {', '.join(sorted(visible))}", file=sys.stderr)
2773
+ return 1
2774
+
2775
+ if not position:
2776
+ print(f"No instance found for {instance_name}")
2777
+ return 1
2778
+
2779
+ # Skip already stopped instances (unless forcing)
2780
+ if not position.get('enabled', False) and not force:
2781
+ print(f"HCOM already stopped for {instance_name}")
2782
+ return 0
2783
+
2784
+ # Disable instance (optionally with force)
2785
+ disable_instance(instance_name, force=force)
2786
+
2787
+ if force:
2788
+ print(f"⚠️ Force stopped HCOM for {instance_name}.")
2789
+ print(f" Bash tool use is now DENIED.")
2790
+ print(f" To restart: hcom start {instance_name}")
2791
+ else:
2792
+ print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
2793
+
2794
+ # Show background log location if applicable
2795
+ if position.get('background'):
2796
+ log_file = position.get('background_log_file', '')
2797
+ if log_file:
2798
+ print(f"\nBackground log: {log_file}")
2799
+ print(f"Monitor: tail -f {log_file}")
2800
+ if not force:
2801
+ print(f"Force stop: hcom stop --force {instance_name}")
2802
+
2803
+ return 0
2804
+
2805
+ def cmd_start(argv: list[str]) -> int:
2806
+ """Enable HCOM participation: hcom start [alias] [--_hcom_session ID]"""
2807
+ # Parse arguments
2808
+ target = None
2809
+ session_id = None
2810
+
2811
+ # Extract --_hcom_session if present
2812
+ if '--_hcom_session' in argv:
2813
+ idx = argv.index('--_hcom_session')
2814
+ if idx + 1 < len(argv):
2815
+ session_id = argv[idx + 1]
2816
+ argv = argv[:idx] + argv[idx + 2:]
2817
+
2818
+ # Remove flags to get target
2819
+ args_without_flags = [a for a in argv if not a.startswith('--')]
2820
+ if args_without_flags:
2821
+ target = args_without_flags[0]
2822
+
2823
+ # Get instance name from injected session or target
2824
+ if session_id and not target:
2825
+ instance_name, existing_data = resolve_instance_name(session_id, get_config().tag)
2826
+
2827
+ # Check if bootstrap needed (before any state changes)
2828
+ needs_bootstrap = not (existing_data and existing_data.get('alias_announced', False))
2829
+
2830
+ # Create instance if it doesn't exist (opt-in for vanilla instances)
2831
+ if not existing_data:
2832
+ initialize_instance_in_position_file(instance_name, session_id)
2833
+ # Enable instance (clears all stop flags)
2834
+ enable_instance(instance_name)
2835
+
2836
+
2837
+
2838
+ print(f"\nStarted HCOM for {instance_name}")
2839
+
2840
+ # Show bootstrap for new instances
2841
+ if needs_bootstrap:
2842
+ print(f"\n\n\n{build_hcom_bootstrap_text(instance_name)}")
2843
+ update_instance_position(instance_name, {'alias_announced': True})
2844
+ else:
2845
+ # Skip already started instances
2846
+ if existing_data.get('enabled', False):
2847
+ print(f"HCOM already started for {instance_name}")
2848
+ return 0
2849
+
2850
+ # Check if background instance has exited permanently
2851
+ if existing_data.get('session_ended') and existing_data.get('background'):
2852
+ session = existing_data.get('session_id', '')
2853
+ print(f"Cannot start {instance_name}: background instance has exited permanently")
2854
+ print(f"Background instances terminate when stopped and cannot be restarted")
2855
+ if session:
2856
+ print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
2857
+ return 1
2858
+
2859
+ # Re-enabling existing instance
2860
+ enable_instance(instance_name)
2861
+ # First time vs rejoining: check if has read messages (pos > starting_pos)
2862
+ has_participated = existing_data.get('pos', 0) > existing_data.get('starting_pos', 0)
2863
+ if has_participated:
2864
+ print(f"\nStarted HCOM for {instance_name}. Rejoined chat.")
2510
2865
  else:
2511
- # Process didn't die from graceful attempt, force kill
2512
- terminate_process(pid, force=True)
2513
- time.sleep(0.1)
2866
+ print(f"\nStarted HCOM for {instance_name}. Joined chat.")
2514
2867
 
2515
- print(f"Killed {target_name} ({instance_type}, {status}{age}, PID {pid})")
2516
- killed_count += 1
2517
- except (TypeError, ValueError) as e:
2518
- print(f"Process {pid} invalid: {e}")
2868
+ # Show bootstrap before re-enabling if needed
2869
+ if needs_bootstrap:
2870
+ print(f"\n\n\n{build_hcom_bootstrap_text(instance_name)}")
2871
+ update_instance_position(instance_name, {'alias_announced': True})
2519
2872
 
2520
- # Mark instance as killed
2521
- update_instance_position(target_name, {'pid': None})
2522
- set_status(target_name, 'killed')
2873
+ return 0
2523
2874
 
2524
- if not instance_name:
2525
- print(f"Killed {killed_count} instance(s)")
2875
+ # CLI path: start specific instance
2876
+ positions = load_all_positions()
2877
+
2878
+ # Handle missing target from external CLI
2879
+ if not target:
2880
+ if os.environ.get('CLAUDECODE') == '1':
2881
+ print("Error: Cannot determine instance", file=sys.stderr)
2882
+ print("Usage: Prompt Claude to run 'hcom start' (or: hcom start <alias>)", file=sys.stderr)
2883
+ else:
2884
+ print("Error: Alias required", file=sys.stderr)
2885
+ print("Usage: hcom start <alias> (or: prompt claude to run 'hcom start')", file=sys.stderr)
2886
+ print("To launch new instances: hcom <count>", file=sys.stderr)
2887
+ return 1
2888
+
2889
+ # Start specific instance
2890
+ instance_name = target
2891
+ position = positions.get(instance_name)
2526
2892
 
2893
+ if not position:
2894
+ print(f"Instance not found: {instance_name}")
2895
+ return 1
2896
+
2897
+ # Skip already started instances
2898
+ if position.get('enabled', False):
2899
+ print(f"HCOM already started for {instance_name}")
2900
+ return 0
2901
+
2902
+ # Check if background instance has exited permanently
2903
+ if position.get('session_ended') and position.get('background'):
2904
+ session = position.get('session_id', '')
2905
+ print(f"Cannot start {instance_name}: background instance has exited permanently")
2906
+ print(f"Background instances terminate when stopped and cannot be restarted")
2907
+ if session:
2908
+ print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
2909
+ return 1
2910
+
2911
+ # Enable instance (clears all stop flags)
2912
+ enable_instance(instance_name)
2913
+
2914
+ print(f"Started HCOM for {instance_name}. Rejoined chat.")
2527
2915
  return 0
2528
2916
 
2529
- def cmd_cleanup(*args):
2917
+ def cmd_reset(argv: list[str]) -> int:
2918
+ """Reset HCOM components: logs, hooks, config
2919
+
2920
+ Usage:
2921
+ hcom reset # Everything (stop all + logs + hooks + config)
2922
+ hcom reset logs # Archive conversation only
2923
+ hcom reset hooks # Remove hooks only
2924
+ hcom reset config # Clear config (backup to config.env.TIMESTAMP)
2925
+ hcom reset logs hooks # Combine targets
2926
+ """
2927
+ # No args = everything
2928
+ do_everything = not argv
2929
+ targets = argv if argv else ['logs', 'hooks', 'config']
2930
+
2931
+ # Validate targets
2932
+ valid = {'logs', 'hooks', 'config'}
2933
+ invalid = [t for t in targets if t not in valid]
2934
+ if invalid:
2935
+ print(f"Invalid target(s): {', '.join(invalid)}", file=sys.stderr)
2936
+ print("Valid targets: logs, hooks, config", file=sys.stderr)
2937
+ return 1
2938
+
2939
+ exit_codes = []
2940
+
2941
+ # Stop all instances if doing everything
2942
+ if do_everything:
2943
+ exit_codes.append(cmd_stop(['all']))
2944
+
2945
+ # Execute based on targets
2946
+ if 'logs' in targets:
2947
+ exit_codes.append(clear())
2948
+
2949
+ if 'hooks' in targets:
2950
+ exit_codes.append(cleanup('--all'))
2951
+ if remove_global_hooks():
2952
+ print("Removed hooks")
2953
+ else:
2954
+ print("Warning: Could not remove hooks. Check your claude settings.json file it might be invalid", file=sys.stderr)
2955
+ exit_codes.append(1)
2956
+
2957
+ if 'config' in targets:
2958
+ config_path = hcom_path(CONFIG_FILE)
2959
+ if config_path.exists():
2960
+ # Backup with timestamp
2961
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
2962
+ backup_path = hcom_path(f'config.env.{timestamp}')
2963
+ shutil.copy2(config_path, backup_path)
2964
+ config_path.unlink()
2965
+ print(f"Config backed up to config.env.{timestamp} and cleared")
2966
+ exit_codes.append(0)
2967
+ else:
2968
+ print("No config file to clear")
2969
+ exit_codes.append(0)
2970
+
2971
+ return max(exit_codes) if exit_codes else 0
2972
+
2973
+ def cleanup(*args: str) -> int:
2530
2974
  """Remove hcom hooks from current directory or all directories"""
2531
2975
  if args and args[0] == '--all':
2532
2976
  directories = set()
2533
-
2977
+
2534
2978
  # Get all directories from current instances
2535
2979
  try:
2536
2980
  positions = load_all_positions()
@@ -2540,9 +2984,27 @@ def cmd_cleanup(*args):
2540
2984
  directories.add(instance_data['directory'])
2541
2985
  except Exception as e:
2542
2986
  print(f"Warning: Could not read current instances: {e}")
2987
+
2988
+ # Also check archived instances for directories (until 0.5.0)
2989
+ try:
2990
+ archive_dir = hcom_path(ARCHIVE_DIR)
2991
+ if archive_dir.exists():
2992
+ for session_dir in archive_dir.iterdir():
2993
+ if session_dir.is_dir() and session_dir.name.startswith('session-'):
2994
+ instances_dir = session_dir / 'instances'
2995
+ if instances_dir.exists():
2996
+ for instance_file in instances_dir.glob('*.json'):
2997
+ try:
2998
+ data = json.loads(instance_file.read_text())
2999
+ if 'directory' in data:
3000
+ directories.add(data['directory'])
3001
+ except Exception:
3002
+ pass
3003
+ except Exception as e:
3004
+ print(f"Warning: Could not read archived instances: {e}")
2543
3005
 
2544
3006
  if not directories:
2545
- print("No directories found in current hcom tracking")
3007
+ print("No directories found in current HCOM tracking")
2546
3008
  return 0
2547
3009
 
2548
3010
  print(f"Found {len(directories)} unique directories to check")
@@ -2583,8 +3045,110 @@ def cmd_cleanup(*args):
2583
3045
  print(message)
2584
3046
  return exit_code
2585
3047
 
2586
- def cmd_send(message):
2587
- """Send message to hcom"""
3048
+ def is_plugin_active() -> bool:
3049
+ """Check if hcom plugin is enabled in Claude Code settings."""
3050
+ settings_path = get_claude_settings_path()
3051
+ if not settings_path.exists():
3052
+ return False
3053
+
3054
+ try:
3055
+ settings = load_settings_json(settings_path, default={})
3056
+ return settings.get('enabledPlugins', {}).get('hcom@hcom', False)
3057
+ except Exception:
3058
+ return False
3059
+
3060
+ def has_direct_hooks_present() -> bool:
3061
+ """Check if direct HCOM hooks exist in settings.json
3062
+ Direct hooks always set env.HCOM, plugin hooks don't touch settings.json.
3063
+ """
3064
+ settings_path = get_claude_settings_path()
3065
+ if not settings_path.exists():
3066
+ return False
3067
+ try:
3068
+ settings = load_settings_json(settings_path, default=None)
3069
+ # Direct hooks marker: HCOM environment variable
3070
+ return bool(settings and 'HCOM' in settings.get('env', {}))
3071
+ except Exception:
3072
+ return False
3073
+
3074
+ def ensure_hooks_current() -> bool:
3075
+ """Ensure hooks match current execution context - called on EVERY command.
3076
+ Manages transition between plugin and direct hooks automatically.
3077
+ Auto-updates hooks if execution context changes (e.g., pip → uvx).
3078
+ Always returns True (warns but never blocks - Claude Code is fault-tolerant)."""
3079
+
3080
+ # Plugin manages hooks?
3081
+ if is_plugin_active():
3082
+ # Clean up any stale direct hooks (plugin/direct transition)
3083
+ if has_direct_hooks_present():
3084
+ print("Plugin detected. Cleaning up direct hooks...", file=sys.stderr)
3085
+ if remove_global_hooks():
3086
+ print("✓ Using plugin hooks exclusively.", file=sys.stderr)
3087
+ # Only ask for restart if inside Claude Code
3088
+ if os.environ.get('CLAUDECODE') == '1':
3089
+ print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
3090
+ print("=" * 60, file=sys.stderr)
3091
+ else:
3092
+ # Failed to remove - warn but continue (plugin hooks still work)
3093
+ print("⚠️ Could not remove direct hooks. Check ~/.claude/settings.json", file=sys.stderr)
3094
+ return True # Plugin hooks active, all good
3095
+
3096
+ # Direct hooks: verify they exist and match current execution context
3097
+ global_settings = get_claude_settings_path()
3098
+
3099
+ # Check if hooks are valid (exist + env var matches current context)
3100
+ hooks_exist = verify_hooks_installed(global_settings)
3101
+ env_var_matches = False
3102
+
3103
+ if hooks_exist:
3104
+ try:
3105
+ settings = load_settings_json(global_settings, default={})
3106
+ if settings is None:
3107
+ settings = {}
3108
+ current_hcom = _build_hcom_env_value()
3109
+ installed_hcom = settings.get('env', {}).get('HCOM')
3110
+ env_var_matches = (installed_hcom == current_hcom)
3111
+ except Exception:
3112
+ # Failed to read settings - try to fix by updating
3113
+ env_var_matches = False
3114
+
3115
+ # Install/update hooks if missing or env var wrong
3116
+ if not hooks_exist or not env_var_matches:
3117
+ try:
3118
+ setup_hooks()
3119
+ if os.environ.get('CLAUDECODE') == '1':
3120
+ print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
3121
+ print("=" * 60, file=sys.stderr)
3122
+ except Exception as e:
3123
+ # Failed to verify/update hooks, but they might still work
3124
+ # Claude Code is fault-tolerant with malformed JSON
3125
+ print(f"⚠️ Could not verify/update hooks: {e}", file=sys.stderr)
3126
+ print("If HCOM doesn't work, check ~/.claude/settings.json", file=sys.stderr)
3127
+
3128
+ return True
3129
+
3130
+ def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> int:
3131
+ """Send message to hcom: hcom send "message" [--_hcom_session ID]"""
3132
+ # Parse message and session_id
3133
+ message = None
3134
+ session_id = None
3135
+
3136
+ # Extract --_hcom_session if present (injected by PreToolUse hook)
3137
+ if '--_hcom_session' in argv:
3138
+ idx = argv.index('--_hcom_session')
3139
+ if idx + 1 < len(argv):
3140
+ session_id = argv[idx + 1]
3141
+ argv = argv[:idx] + argv[idx + 2:] # Remove flag and value
3142
+
3143
+ # First non-flag argument is the message
3144
+ if argv:
3145
+ message = argv[0]
3146
+
3147
+ # Check message is provided
3148
+ if not message:
3149
+ print(format_error("No message provided"), file=sys.stderr)
3150
+ return 1
3151
+
2588
3152
  # Check if hcom files exist
2589
3153
  log_file = hcom_path(LOG_FILE)
2590
3154
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -2605,7 +3169,7 @@ def cmd_send(message):
2605
3169
  try:
2606
3170
  positions = load_all_positions()
2607
3171
  all_instances = list(positions.keys())
2608
- sender_name = get_config_value('sender_name', 'bigboss')
3172
+ sender_name = SENDER
2609
3173
  all_names = all_instances + [sender_name]
2610
3174
  unmatched = [m for m in mentions
2611
3175
  if not any(name.lower().startswith(m.lower()) for name in all_names)]
@@ -2614,90 +3178,72 @@ def cmd_send(message):
2614
3178
  except Exception:
2615
3179
  pass # Don't fail on warning
2616
3180
 
2617
- # Determine sender: lookup by launch_id, fallback to config
2618
- sender_name = None
2619
- launch_id = os.environ.get('HCOM_LAUNCH_ID')
2620
- if launch_id:
3181
+ # Determine sender from injected session_id or CLI
3182
+ if session_id and not force_cli:
3183
+ # Instance context - resolve name from session_id (searches existing instances first)
2621
3184
  try:
2622
- mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
2623
- if mapping_file.exists():
2624
- sender_name = mapping_file.read_text(encoding='utf-8').strip()
2625
- except Exception:
2626
- pass
3185
+ sender_name, instance_data = resolve_instance_name(session_id, get_config().tag)
3186
+ except (ValueError, Exception) as e:
3187
+ print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
3188
+ return 1
2627
3189
 
2628
- if not sender_name:
2629
- sender_name = get_config_value('sender_name', 'bigboss')
2630
-
2631
- if send_message(sender_name, message):
2632
- # For instances: check for new messages and display immediately
2633
- if launch_id: # Only for instances with HCOM_LAUNCH_ID
2634
- messages = get_unread_messages(sender_name, update_position=True)
2635
- if messages:
2636
- max_msgs = get_config_value('max_messages_per_delivery', 50)
2637
- messages_to_show = messages[:max_msgs]
2638
- formatted = format_hook_messages(messages_to_show, sender_name)
2639
- print(f"Message sent\n\n{formatted}", file=sys.stderr)
2640
- else:
2641
- print("Message sent", file=sys.stderr)
3190
+ # Initialize instance if doesn't exist (first use)
3191
+ if not instance_data:
3192
+ initialize_instance_in_position_file(sender_name, session_id)
3193
+ instance_data = load_instance_position(sender_name)
3194
+
3195
+ # Check force_closed
3196
+ if instance_data.get('force_closed'):
3197
+ print(format_error(f"HCOM force stopped for this instance. To recover, delete instance file: rm ~/.hcom/instances/{sender_name}.json"), file=sys.stderr)
3198
+ return 1
3199
+
3200
+ # Check enabled state
3201
+ if not instance_data.get('enabled', False):
3202
+ print(format_error("HCOM not started for this instance. To send a message first run: 'hcom start' then use hcom send"), file=sys.stderr)
3203
+ return 1
3204
+
3205
+ # Send message
3206
+ if not send_message(sender_name, message):
3207
+ print(format_error("Failed to send message"), file=sys.stderr)
3208
+ return 1
3209
+
3210
+ # Show unread messages
3211
+ messages = get_unread_messages(sender_name, update_position=True)
3212
+ if messages:
3213
+ max_msgs = MAX_MESSAGES_PER_DELIVERY
3214
+ formatted = format_hook_messages(messages[:max_msgs], sender_name)
3215
+ print(f"Message sent\n\n{formatted}", file=sys.stderr)
2642
3216
  else:
2643
- # Bigboss: just confirm send
2644
3217
  print("Message sent", file=sys.stderr)
2645
-
2646
- # Show cli_hints if configured (non-interactive mode)
2647
- if not is_interactive():
2648
- show_cli_hints()
2649
-
3218
+
2650
3219
  return 0
2651
3220
  else:
2652
- print(format_error("Failed to send message"), file=sys.stderr)
2653
- return 1
3221
+ # CLI context - no session_id or force_cli=True
2654
3222
 
2655
- def cmd_resume_merge(alias: str) -> int:
2656
- """Resume/merge current instance into an existing instance by alias.
3223
+ # Warn if inside Claude Code but no session_id (hooks not working)
3224
+ if os.environ.get('CLAUDECODE') == '1' and not session_id and not force_cli:
3225
+ print(f"⚠️ Cannot determine alias - message sent as '{SENDER}'", file=sys.stderr)
3226
+ print(" Prompt Claude to send a hcom message instead of using bash mode (! prefix).", file=sys.stderr)
2657
3227
 
2658
- INTERNAL COMMAND: Only called via 'eval $HCOM send --resume alias' during implicit resume workflow.
2659
- Not meant for direct CLI usage.
2660
- """
2661
- # Get current instance name via launch_id mapping (same mechanism as cmd_send)
2662
- # The mapping is created by init_hook_context() when hooks run
2663
- launch_id = os.environ.get('HCOM_LAUNCH_ID')
2664
- if not launch_id:
2665
- print(format_error("Not in HCOM instance context - no launch ID"), file=sys.stderr)
2666
- return 1
2667
3228
 
2668
- instance_name = None
2669
- try:
2670
- mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
2671
- if mapping_file.exists():
2672
- instance_name = mapping_file.read_text(encoding='utf-8').strip()
2673
- except Exception:
2674
- pass
3229
+ sender_name = SENDER
2675
3230
 
2676
- if not instance_name:
2677
- print(format_error("Could not determine instance name"), file=sys.stderr)
2678
- return 1
2679
-
2680
- # Sanitize alias: only allow alphanumeric, dash, underscore
2681
- # This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
2682
- if not re.match(r'^[A-Za-z0-9\-_]+$', alias):
2683
- print(format_error("Invalid alias format. Use alphanumeric, dash, or underscore only"), file=sys.stderr)
2684
- return 1
3231
+ if not send_message(sender_name, message):
3232
+ print(format_error("Failed to send message"), file=sys.stderr)
3233
+ return 1
2685
3234
 
2686
- # Attempt to merge current instance into target alias
2687
- status = merge_instance_immediately(instance_name, alias)
3235
+ if not quiet:
3236
+ print(f"✓ Sent from {sender_name}", file=sys.stderr)
2688
3237
 
2689
- # Handle results
2690
- if not status:
2691
- # Empty status means names matched (from_name == to_name)
2692
- status = f"[SUCCESS] ✓ Already using alias {alias}"
3238
+ return 0
2693
3239
 
2694
- # Print status and return
2695
- print(status, file=sys.stderr)
2696
- return 0 if status.startswith('[SUCCESS]') else 1
3240
+ def send_cli(message: str, quiet: bool = False) -> int:
3241
+ """Force CLI sender (skip outbox, use config sender name)"""
3242
+ return cmd_send([message], force_cli=True, quiet=quiet)
2697
3243
 
2698
3244
  # ==================== Hook Helpers ====================
2699
3245
 
2700
- def format_hook_messages(messages, instance_name):
3246
+ def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
2701
3247
  """Format messages for hook feedback"""
2702
3248
  if len(messages) == 1:
2703
3249
  msg = messages[0]
@@ -2706,147 +3252,117 @@ def format_hook_messages(messages, instance_name):
2706
3252
  parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
2707
3253
  reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
2708
3254
 
2709
- # Only append instance_hints to messages (first_use_text is handled separately)
2710
- instance_hints = get_config_value('instance_hints', '')
2711
- if instance_hints:
2712
- reason = f"{reason} | [{instance_hints}]"
3255
+ # Only append hints to messages
3256
+ hints = get_config().hints
3257
+ if hints:
3258
+ reason = f"{reason} | [{hints}]"
2713
3259
 
2714
3260
  return reason
2715
3261
 
2716
3262
  # ==================== Hook Handlers ====================
2717
3263
 
2718
- def init_hook_context(hook_data, hook_type=None):
2719
- """Initialize instance context - shared by post/stop/notify hooks"""
3264
+ def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -> tuple[str, dict[str, Any], bool]:
3265
+ """
3266
+ Initialize instance context. Flow:
3267
+ 1. Resolve instance name (search by session_id, generate if not found)
3268
+ 2. Create instance file if fresh start in UserPromptSubmit
3269
+ 3. Build updates dict
3270
+ 4. Return (instance_name, updates, is_matched_resume)
3271
+ """
2720
3272
  session_id = hook_data.get('session_id', '')
2721
3273
  transcript_path = hook_data.get('transcript_path', '')
2722
- prefix = os.environ.get('HCOM_PREFIX')
2723
-
2724
- instances_dir = hcom_path(INSTANCES_DIR)
2725
- instance_name = None
2726
- merged_state = None
2727
-
2728
- # Check if current session_id matches any existing instance
2729
- # This maintains identity after resume/merge operations
2730
- if not instance_name and session_id and instances_dir.exists():
2731
- for instance_file in instances_dir.glob("*.json"):
2732
- try:
2733
- data = load_instance_position(instance_file.stem)
2734
- if session_id == data.get('session_id'):
2735
- instance_name = instance_file.stem
2736
- merged_state = data
2737
- log_hook_error(f'DEBUG: Session_id {session_id[:8]} matched {instance_file.stem}, reusing that name')
2738
- break
2739
- except (json.JSONDecodeError, OSError, KeyError):
2740
- continue
3274
+ tag = get_config().tag
2741
3275
 
2742
- # If not found or not resuming, generate new name from session_id
2743
- if not instance_name:
2744
- instance_name = get_display_name(session_id, prefix)
2745
- # DEBUG: Log name generation
2746
- log_hook_error(f'DEBUG: Generated instance_name={instance_name} from session_id={session_id[:8] if session_id else "None"}')
2747
-
2748
- # Save launch_id → instance_name mapping for cmd_send()
2749
- launch_id = os.environ.get('HCOM_LAUNCH_ID')
2750
- if launch_id:
2751
- try:
2752
- mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}', ensure_parent=True)
2753
- mapping_file.write_text(instance_name, encoding='utf-8')
2754
- log_hook_error(f'DEBUG: FINAL - Wrote launch_map_{launch_id} → {instance_name} (session_id={session_id[:8] if session_id else "None"})')
2755
- except Exception:
2756
- pass # Non-critical
3276
+ # Resolve instance name - existing_data is None for fresh starts
3277
+ instance_name, existing_data = resolve_instance_name(session_id, tag)
2757
3278
 
2758
3279
  # Save migrated data if we have it
2759
- if merged_state:
2760
- save_instance_position(instance_name, merged_state)
2761
-
2762
- # Check if instance is brand new or pre-existing (before creation (WWJD))
2763
- instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
2764
- is_new_instance = not instance_file.exists()
3280
+ if existing_data:
3281
+ save_instance_position(instance_name, existing_data)
2765
3282
 
2766
- # Skip instance creation for unmatched SessionStart resumes (prevents orphans)
2767
- # Instance will be created in UserPromptSubmit with correct session_id
2768
- should_create_instance = not (
2769
- hook_type == 'sessionstart' and
2770
- hook_data.get('source', 'startup') == 'resume' and not merged_state
2771
- )
2772
- if should_create_instance:
3283
+ # Create instance file if fresh start in UserPromptSubmit
3284
+ if existing_data is None and hook_type == 'userpromptsubmit':
2773
3285
  initialize_instance_in_position_file(instance_name, session_id)
2774
3286
 
2775
- # Prepare updates
3287
+ # Build updates dict
2776
3288
  updates: dict[str, Any] = {
2777
3289
  'directory': str(Path.cwd()),
3290
+ 'tag': tag,
2778
3291
  }
2779
3292
 
2780
- # Update session_id (overwrites previous)
2781
3293
  if session_id:
2782
3294
  updates['session_id'] = session_id
2783
3295
 
2784
- # Update transcript_path to current
2785
3296
  if transcript_path:
2786
3297
  updates['transcript_path'] = transcript_path
2787
3298
 
2788
- # Always update PID to current (fixes stale PID on implicit resume)
2789
- updates['pid'] = os.getppid()
2790
-
2791
- # Add background status if applicable
2792
3299
  bg_env = os.environ.get('HCOM_BACKGROUND')
2793
3300
  if bg_env:
2794
3301
  updates['background'] = True
2795
3302
  updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
2796
3303
 
2797
- # Return flags indicating resume state
2798
- is_resume_match = merged_state is not None
2799
- return instance_name, updates, is_resume_match, is_new_instance
3304
+ # Simple boolean: matched resume if existing_data found
3305
+ is_matched_resume = (existing_data is not None)
3306
+
3307
+ return instance_name, updates, is_matched_resume
3308
+
3309
+ def pretooluse_decision(decision: str, reason: str) -> None:
3310
+ """Exit PreToolUse hook with permission decision"""
3311
+ output = {
3312
+ "hookSpecificOutput": {
3313
+ "hookEventName": "PreToolUse",
3314
+ "permissionDecision": decision,
3315
+ "permissionDecisionReason": reason
3316
+ }
3317
+ }
3318
+ print(json.dumps(output, ensure_ascii=False))
3319
+ sys.exit(EXIT_SUCCESS)
2800
3320
 
2801
- def handle_pretooluse(hook_data, instance_name, updates):
2802
- """Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
3321
+ def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3322
+ """Handle PreToolUse hook - check force_closed, inject session_id"""
3323
+ instance_data = load_instance_position(instance_name)
2803
3324
  tool_name = hook_data.get('tool_name', '')
3325
+ session_id = hook_data.get('session_id', '')
3326
+
3327
+ # FORCE CLOSE CHECK - deny ALL tools
3328
+ if instance_data.get('force_closed'):
3329
+ pretooluse_decision("deny", f"FORCE CLOSED: The user has force stopped HCOM for this instance (you, {instance_name}). Stop using all tools immediately and wait for user instructions.")
2804
3330
 
2805
- # Non-HCOM_SEND tools: record status (they'll run without permission check)
2806
- set_status(instance_name, 'tool_pending', tool_name)
3331
+ # Record status for tool execution tracking (only if enabled)
3332
+ if instance_data.get('enabled', False):
3333
+ set_status(instance_name, 'tool_pending', tool_name)
2807
3334
 
2808
- # Handle HCOM commands in Bash
2809
- if tool_name == 'Bash':
3335
+ # Inject session_id into hcom commands via updatedInput
3336
+ if tool_name == 'Bash' and session_id:
2810
3337
  command = hook_data.get('tool_input', {}).get('command', '')
2811
- script_path = str(Path(__file__).resolve())
2812
-
2813
- # === Auto-approve ALL 'eval $HCOM send' commands (including --resume) ===
2814
- # This includes:
2815
- # - eval $HCOM send "message" (normal messaging between instances)
2816
- # - eval $HCOM send --resume alias (resume/merge operation)
2817
- if ('$HCOM send' in command or
2818
- 'hcom send' in command or
2819
- (script_path in command and ' send ' in command)):
3338
+
3339
+ # Match hcom commands for session_id injection and auto-approval
3340
+ matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
3341
+ if matches:
3342
+ # Inject all if chained (&&, ||, ;, |), otherwise first only (avoids quoted text in messages)
3343
+ inject_all = len(matches) > 1 and any(op in command[matches[0].end():matches[1].start()] for op in ['&&', '||', ';', '|'])
3344
+ modified_command = HCOM_COMMAND_PATTERN.sub(rf'\g<0> --_hcom_session {session_id}', command, count=0 if inject_all else 1)
3345
+
2820
3346
  output = {
2821
3347
  "hookSpecificOutput": {
2822
3348
  "hookEventName": "PreToolUse",
2823
3349
  "permissionDecision": "allow",
2824
- "permissionDecisionReason": "HCOM send command auto-approved"
3350
+ "updatedInput": {
3351
+ "command": modified_command
3352
+ }
2825
3353
  }
2826
3354
  }
2827
3355
  print(json.dumps(output, ensure_ascii=False))
2828
3356
  sys.exit(EXIT_SUCCESS)
2829
3357
 
2830
3358
 
2831
- def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
2832
- """Safely exit stop hook with proper status tracking"""
2833
- try:
2834
- set_status(instance_name, 'stop_exit')
2835
- except (OSError, PermissionError):
2836
- pass # Silently handle any errors
2837
- sys.exit(code)
2838
3359
 
2839
- def handle_stop(hook_data, instance_name, updates):
3360
+ def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
2840
3361
  """Handle Stop hook - poll for messages and deliver"""
2841
- parent_pid = os.getppid()
2842
- log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
2843
- log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
2844
-
2845
3362
 
2846
3363
  try:
2847
- entry_time = time.time()
2848
- updates['last_stop'] = entry_time
2849
- timeout = get_config_value('wait_timeout', 1800)
3364
+ updates['last_stop'] = time.time()
3365
+ timeout = get_config().timeout
2850
3366
  updates['wait_timeout'] = timeout
2851
3367
  set_status(instance_name, 'waiting')
2852
3368
 
@@ -2856,45 +3372,63 @@ def handle_stop(hook_data, instance_name, updates):
2856
3372
  log_hook_error(f'stop:update_instance_position({instance_name})', e)
2857
3373
 
2858
3374
  start_time = time.time()
2859
- log_hook_error(f'stop:start_time_pid_{os.getpid()}')
2860
3375
 
2861
3376
  try:
2862
- loop_count = 0
3377
+ first_poll = True
2863
3378
  last_heartbeat = start_time
2864
- # STEP 4: Actual polling loop - this IS the holding pattern
3379
+ # Actual polling loop - this IS the holding pattern
2865
3380
  while time.time() - start_time < timeout:
2866
- if loop_count == 0:
2867
- time.sleep(0.1) # Initial wait before first poll
2868
- loop_count += 1
3381
+ if first_poll:
3382
+ first_poll = False
2869
3383
 
2870
- # Check if parent is alive
2871
- if not is_parent_alive(parent_pid):
2872
- log_hook_error(f'stop:parent_not_alive_pid_{os.getpid()}')
2873
- safe_exit_with_status(instance_name, EXIT_SUCCESS)
2874
-
2875
- # Load instance data once per poll (needed for messages and user input check)
3384
+ # Reload instance data each poll iteration
2876
3385
  instance_data = load_instance_position(instance_name)
2877
3386
 
2878
- # Check if user input is pending - exit cleanly if recent input
3387
+ # Check flag file FIRST (highest priority coordination signal)
3388
+ flag_file = get_user_input_flag_file(instance_name)
3389
+ if flag_file.exists():
3390
+ try:
3391
+ flag_file.unlink()
3392
+ except (FileNotFoundError, PermissionError):
3393
+ # Already deleted or locked, continue anyway
3394
+ pass
3395
+ sys.exit(EXIT_SUCCESS)
3396
+
3397
+ # Check if session ended (SessionEnd hook fired) - exit without changing status
3398
+ if instance_data.get('session_ended'):
3399
+ sys.exit(EXIT_SUCCESS) # Don't overwrite session_ended status
3400
+
3401
+ # Check if user input is pending (timestamp fallback) - exit cleanly if recent input
2879
3402
  last_user_input = instance_data.get('last_user_input', 0)
2880
3403
  if time.time() - last_user_input < 0.2:
2881
- log_hook_error(f'stop:user_input_pending_exiting_pid_{os.getpid()}')
2882
- safe_exit_with_status(instance_name, EXIT_SUCCESS)
3404
+ sys.exit(EXIT_SUCCESS) # Don't overwrite status - let current status remain
3405
+
3406
+ # Check if stopped/disabled - exit cleanly
3407
+ if not instance_data.get('enabled', False):
3408
+ sys.exit(EXIT_SUCCESS) # Preserve 'stopped' status set by cmd_stop
2883
3409
 
2884
3410
  # Check for new messages and deliver
2885
3411
  if messages := get_unread_messages(instance_name, update_position=True):
2886
- messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
3412
+ messages_to_show = messages[:MAX_MESSAGES_PER_DELIVERY]
2887
3413
  reason = format_hook_messages(messages_to_show, instance_name)
2888
3414
  set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
2889
3415
 
2890
- log_hook_error(f'stop:delivering_message_pid_{os.getpid()}')
2891
3416
  output = {"decision": "block", "reason": reason}
2892
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2893
- sys.exit(EXIT_BLOCK)
3417
+ output_json = json.dumps(output, ensure_ascii=False)
3418
+
3419
+ # Log what we're about to output for debugging
3420
+ log_hook_error(f'stop:delivering_message|output_len={len(output_json)}', None)
3421
+ log_hook_error(f'stop:output_json|{output_json}', None)
2894
3422
 
2895
- # Update heartbeat every 5 seconds instead of every poll
3423
+ # Use JSON output method: stdout + exit 0 (per Claude Code hooks reference)
3424
+ # The "decision": "block" field prevents stoppage, allowing next poll cycle
3425
+ print(output_json)
3426
+ sys.stdout.flush()
3427
+ sys.exit(EXIT_SUCCESS)
3428
+
3429
+ # Update heartbeat every 0.5 seconds for staleness detection
2896
3430
  now = time.time()
2897
- if now - last_heartbeat >= 5.0:
3431
+ if now - last_heartbeat >= 0.5:
2898
3432
  try:
2899
3433
  update_instance_position(instance_name, {'last_stop': now})
2900
3434
  last_heartbeat = now
@@ -2913,183 +3447,298 @@ def handle_stop(hook_data, instance_name, updates):
2913
3447
  except Exception as e:
2914
3448
  # Log error and exit gracefully
2915
3449
  log_hook_error('handle_stop', e)
2916
- safe_exit_with_status(instance_name, EXIT_SUCCESS)
3450
+ sys.exit(EXIT_SUCCESS) # Preserve previous status on exception
2917
3451
 
2918
- def handle_notify(hook_data, instance_name, updates):
3452
+ def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
2919
3453
  """Handle Notification hook - track permission requests"""
2920
3454
  updates['notification_message'] = hook_data.get('message', '')
2921
3455
  update_instance_position(instance_name, updates)
2922
3456
  set_status(instance_name, 'blocked', hook_data.get('message', ''))
2923
3457
 
2924
- def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
3458
+ def get_user_input_flag_file(instance_name: str) -> Path:
3459
+ """Get path to user input coordination flag file"""
3460
+ return hcom_path(INSTANCES_DIR, f'{instance_name}.user_input')
3461
+
3462
+ def wait_for_stop_exit(instance_name: str, max_wait: float = 0.2) -> int:
3463
+ """
3464
+ Wait for Stop hook to exit using flag file coordination.
3465
+ Returns wait time in ms.
3466
+
3467
+ Strategy:
3468
+ 1. Create flag file
3469
+ 2. Wait for Stop hook to delete it (proof it exited)
3470
+ 3. Fallback to timeout if Stop hook doesn't delete flag
3471
+ """
3472
+ start = time.time()
3473
+ flag_file = get_user_input_flag_file(instance_name)
3474
+
3475
+ # Wait for flag file to be deleted by Stop hook
3476
+ while flag_file.exists() and time.time() - start < max_wait:
3477
+ time.sleep(0.01)
3478
+
3479
+ return int((time.time() - start) * 1000)
3480
+
3481
+ 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:
2925
3482
  """Handle UserPromptSubmit hook - track when user sends messages"""
2926
- # Update last user input timestamp
2927
- updates['last_user_input'] = time.time()
2928
- update_instance_position(instance_name, updates)
3483
+ is_enabled = instance_data.get('enabled', False) if instance_data else False
3484
+ last_stop = instance_data.get('last_stop', 0) if instance_data else 0
3485
+ alias_announced = instance_data.get('alias_announced', False) if instance_data else False
2929
3486
 
2930
- # Wait for Stop hook to detect timestamp and exit (prevents api errors / race condition)
2931
- time.sleep(0.15)
3487
+ # Session_ended prevents user recieving messages(?) so reset it.
3488
+ if is_matched_resume and instance_data and instance_data.get('session_ended'):
3489
+ update_instance_position(instance_name, {'session_ended': False})
3490
+ instance_data['session_ended'] = False # Resume path reactivates Stop hook polling
3491
+
3492
+ # Coordinate with Stop hook only if enabled AND Stop hook is active
3493
+ stop_is_active = (time.time() - last_stop) < 1.0
3494
+
3495
+ if is_enabled and stop_is_active:
3496
+ # Create flag file for coordination
3497
+ flag_file = get_user_input_flag_file(instance_name)
3498
+ try:
3499
+ flag_file.touch()
3500
+ except (OSError, PermissionError):
3501
+ # Failed to create flag, fall back to timestamp-only coordination
3502
+ pass
3503
+
3504
+ # Set timestamp (backup mechanism)
3505
+ updates['last_user_input'] = time.time()
3506
+ update_instance_position(instance_name, updates)
3507
+
3508
+ # Wait for Stop hook to delete flag file
3509
+ wait_for_stop_exit(instance_name)
2932
3510
 
2933
- send_cmd = build_send_command('your message')
2934
- resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
2935
3511
  # Build message based on what happened
2936
3512
  msg = None
2937
- if is_resume_match:
2938
- msg = f"[Resumed with hcom chat alias: {instance_name}. Use {send_cmd} to send messages]"
2939
- elif is_new_instance:
2940
- # Unmatched resume - show critical recovery message
2941
- msg = (
2942
- f"[HCOM RESUME DETECTED - CRITICAL ACTION REQUIRED "
2943
- f"You MUST recover your HCOM identity to maintain conversation context "
2944
- f"Run: {resume_cmd} "
2945
- f"This is REQUIRED for message history and position tracking]"
2946
- )
2947
- else:
2948
- # Check if we need to announce alias (normal startup)
2949
- instance_data = load_instance_position(instance_name)
2950
- alias_announced = instance_data.get('alias_announced', False)
2951
- if not alias_announced:
2952
- msg = f"[Your hcom chat alias is {instance_name}. You can at-mention others in hcom chat by their alias to DM them. To send a message use: {send_cmd}]"
3513
+
3514
+ # Determine if this is an HCOM-launched instance
3515
+ is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
3516
+
3517
+ # Show bootstrap if not already announced
3518
+ if not alias_announced:
3519
+ if is_hcom_launched:
3520
+ # HCOM-launched instance - show bootstrap immediately
3521
+ msg = build_hcom_bootstrap_text(instance_name)
2953
3522
  update_instance_position(instance_name, {'alias_announced': True})
3523
+ else:
3524
+ # Vanilla Claude instance - check if user is about to run an hcom command
3525
+ user_prompt = hook_data.get('prompt', '')
3526
+ hcom_command_pattern = r'\bhcom\s+\w+'
3527
+ if re.search(hcom_command_pattern, user_prompt, re.IGNORECASE):
3528
+ # Bootstrap not shown yet - show it preemptively before hcom command runs
3529
+ msg = "[HCOM COMMAND DETECTED]\n\n"
3530
+ msg += build_hcom_bootstrap_text(instance_name)
3531
+ update_instance_position(instance_name, {'alias_announced': True})
3532
+
3533
+ # Add resume status note if we showed bootstrap for a matched resume
3534
+ if msg and is_matched_resume:
3535
+ if is_enabled:
3536
+ msg += "\n[Session resumed. HCOM started for this instance - will receive chat messages. Your alias and conversation history preserved.]"
3537
+ else:
3538
+ msg += "\n[Session resumed. HCOM stopped for this instance - will not receive chat messages. Run 'hcom start' to rejoin chat. Your alias and conversation history preserved.]"
2954
3539
 
2955
3540
  if msg:
2956
- output = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": msg}}
2957
- print(json.dumps(output))
3541
+ output = {
3542
+ "hookSpecificOutput": {
3543
+ "hookEventName": "UserPromptSubmit",
3544
+ "additionalContext": msg
3545
+ }
3546
+ }
3547
+ print(json.dumps(output), file=sys.stdout)
2958
3548
 
2959
- def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
2960
- """Handle SessionStart hook - deliver welcome/resume message"""
2961
- source = hook_data.get('source', 'startup')
3549
+ def handle_sessionstart(hook_data: dict[str, Any]) -> None:
3550
+ """Handle SessionStart hook - initial msg & reads environment variables"""
3551
+ # Only show message for HCOM-launched instances
3552
+ if os.environ.get('HCOM_LAUNCHED') != '1':
3553
+ return
2962
3554
 
2963
- log_hook_error(f'sessionstart:is_resume_match_{is_resume_match}')
2964
- log_hook_error(f'sessionstart:instance_name_{instance_name}')
2965
- log_hook_error(f'sessionstart:source_{source}')
2966
- log_hook_error(f'sessionstart:updates_{updates}')
2967
- log_hook_error(f'sessionstart:hook_data_{hook_data}')
3555
+ # Build minimal context from environment
3556
+ parts = ["[HCOM active]"]
2968
3557
 
2969
- # Reset alias_announced flag so alias shows again on resume/clear/compact
2970
- updates['alias_announced'] = False
3558
+ if agent_type := os.environ.get('HCOM_SUBAGENT_TYPE'):
3559
+ parts.append(f"[agent: {agent_type}]")
2971
3560
 
2972
- # Only update instance position if file exists (startup or matched resume)
2973
- # For unmatched resumes, skip - UserPromptSubmit will create the file with correct session_id
2974
- if source == 'startup' or is_resume_match:
2975
- update_instance_position(instance_name, updates)
2976
- set_status(instance_name, 'session_start')
2977
-
2978
- log_hook_error(f'sessionstart:instance_name_after_update_{instance_name}')
2979
-
2980
- # Build send command using helper
2981
- send_cmd = build_send_command('your message')
2982
- help_text = f"[Welcome! HCOM chat active. Send messages: {send_cmd}]"
2983
-
2984
- # Add subagent type if this is a named agent
2985
- subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
2986
- if subagent_type:
2987
- help_text += f" [Subagent: {subagent_type}]"
2988
-
2989
- # Add first use text only on startup
2990
- if source == 'startup':
2991
- first_use_text = get_config_value('first_use_text', '')
2992
- if first_use_text:
2993
- help_text += f" [{first_use_text}]"
2994
- elif source == 'resume':
2995
- if is_resume_match:
2996
- help_text += f" [Resumed alias: {instance_name}]"
2997
- else:
2998
- help_text += f" [Session resumed]"
3561
+ if tag := os.environ.get('HCOM_TAG'):
3562
+ parts.append(f"[tag: {tag}]")
2999
3563
 
3000
- # Add instance hints to all messages
3001
- instance_hints = get_config_value('instance_hints', '')
3002
- if instance_hints:
3003
- help_text += f" [{instance_hints}]"
3564
+ help_text = " ".join(parts)
3565
+
3566
+ # First time: no instance files or archives exist
3567
+ is_first_time = not any(hcom_path().rglob('*.json'))
3568
+
3569
+ is_first_time = True
3570
+ if is_first_time:
3571
+ help_text += """
3572
+
3573
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3574
+ Welcome to Hook Comms!
3575
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3576
+ Dashboard: hcom watch
3577
+ Toggle on/off: hcom stop / hcom start
3578
+ Launch: hcom 3
3579
+ All commands: hcom help
3580
+ Config: ~/.hcom/config.env
3581
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3582
+
3583
+ """
3004
3584
 
3005
- # Output as additionalContext using hookSpecificOutput format
3006
3585
  output = {
3007
3586
  "hookSpecificOutput": {
3008
3587
  "hookEventName": "SessionStart",
3009
3588
  "additionalContext": help_text
3010
3589
  }
3011
3590
  }
3591
+
3012
3592
  print(json.dumps(output))
3013
3593
 
3594
+ def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3595
+ """Handle SessionEnd hook - mark session as ended and set final status"""
3596
+ reason = hook_data.get('reason', 'unknown')
3597
+
3598
+ # Set session_ended flag to tell Stop hook to exit
3599
+ updates['session_ended'] = True
3600
+
3601
+ # Set status with reason as context (reason: clear, logout, prompt_input_exit, other)
3602
+ set_status(instance_name, 'session_ended', reason)
3603
+
3604
+ try:
3605
+ update_instance_position(instance_name, updates)
3606
+ except Exception as e:
3607
+ log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
3608
+
3609
+ def should_skip_vanilla_instance(hook_type: str, hook_data: dict) -> bool:
3610
+ """
3611
+ Returns True if hook should exit early.
3612
+ Vanilla instances (not HCOM-launched) exit early unless:
3613
+ - Enabled
3614
+ - PreToolUse (handles opt-in)
3615
+ - UserPromptSubmit with hcom command in prompt (shows preemptive bootstrap)
3616
+ """
3617
+ # PreToolUse always runs (handles toggle commands)
3618
+ # HCOM-launched instances always run
3619
+ if hook_type == 'pre' or os.environ.get('HCOM_LAUNCHED') == '1':
3620
+ return False
3621
+
3622
+ session_id = hook_data.get('session_id', '')
3623
+ if not session_id: # No session_id = can't identify instance, skip hook
3624
+ return True
3625
+
3626
+ instance_name = get_display_name(session_id, get_config().tag)
3627
+ instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3628
+
3629
+ if not instance_file.exists():
3630
+ # Allow UserPromptSubmit if prompt contains hcom command
3631
+ if hook_type == 'userpromptsubmit':
3632
+ user_prompt = hook_data.get('prompt', '')
3633
+ return not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE)
3634
+ return True
3635
+
3636
+ return False
3637
+
3014
3638
  def handle_hook(hook_type: str) -> None:
3015
3639
  """Unified hook handler for all HCOM hooks"""
3016
- if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
3640
+ hook_data = json.load(sys.stdin)
3641
+
3642
+ if not ensure_hcom_directories():
3643
+ log_hook_error('handle_hook', Exception('Failed to create directories'))
3017
3644
  sys.exit(EXIT_SUCCESS)
3018
3645
 
3019
- hook_data = json.load(sys.stdin)
3020
- log_hook_error(f'handle_hook:hook_data_{hook_data}')
3646
+ # SessionStart is standalone - no instance files
3647
+ if hook_type == 'sessionstart':
3648
+ handle_sessionstart(hook_data)
3649
+ sys.exit(EXIT_SUCCESS)
3650
+
3651
+ # Vanilla instance check - exit early if should skip
3652
+ if should_skip_vanilla_instance(hook_type, hook_data):
3653
+ sys.exit(EXIT_SUCCESS)
3654
+
3655
+ # Initialize instance context (creates file if needed, reuses existing if session_id matches)
3656
+ instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
3657
+
3658
+ # Load instance data once (for enabled check and to pass to handlers)
3659
+ instance_data = None
3660
+ if hook_type != 'pre':
3661
+ instance_data = load_instance_position(instance_name)
3021
3662
 
3022
- # DEBUG: Log which hook is being called with which session_id
3023
- session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
3024
- log_hook_error(f'DEBUG: Hook {hook_type} called with session_id={session_id_short}')
3663
+ # Skip enabled check for UserPromptSubmit when bootstrap needs to be shown
3664
+ # (alias_announced=false means bootstrap hasn't been shown yet)
3665
+ skip_enabled_check = (hook_type == 'userpromptsubmit' and
3666
+ not instance_data.get('alias_announced', False))
3025
3667
 
3026
- instance_name, updates, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
3668
+ if not skip_enabled_check and not instance_data.get('enabled', False):
3669
+ sys.exit(EXIT_SUCCESS)
3027
3670
 
3028
3671
  match hook_type:
3029
3672
  case 'pre':
3030
- handle_pretooluse(hook_data, instance_name, updates)
3031
- case 'stop':
3032
- handle_stop(hook_data, instance_name, updates)
3673
+ handle_pretooluse(hook_data, instance_name)
3674
+ case 'poll':
3675
+ handle_stop(hook_data, instance_name, updates, instance_data)
3033
3676
  case 'notify':
3034
- handle_notify(hook_data, instance_name, updates)
3677
+ handle_notify(hook_data, instance_name, updates, instance_data)
3035
3678
  case 'userpromptsubmit':
3036
- handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
3037
- case 'sessionstart':
3038
- handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
3039
-
3040
- log_hook_error(f'handle_hook:instance_name_{instance_name}')
3041
-
3679
+ handle_userpromptsubmit(hook_data, instance_name, updates, is_matched_resume, instance_data)
3680
+ case 'sessionend':
3681
+ handle_sessionend(hook_data, instance_name, updates, instance_data)
3042
3682
 
3043
3683
  sys.exit(EXIT_SUCCESS)
3044
3684
 
3045
3685
 
3046
3686
  # ==================== Main Entry Point ====================
3047
3687
 
3048
- def main(argv=None):
3688
+ def main(argv: list[str] | None = None) -> int | None:
3049
3689
  """Main command dispatcher"""
3050
3690
  if argv is None:
3051
- argv = sys.argv
3052
-
3053
- if len(argv) < 2:
3054
- return cmd_help()
3055
-
3056
- cmd = argv[1]
3057
-
3058
- match cmd:
3059
- case 'help' | '--help':
3691
+ argv = sys.argv[1:]
3692
+ else:
3693
+ argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
3694
+
3695
+ # Hook handlers only (called BY hooks, not users)
3696
+ if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
3697
+ handle_hook(argv[0])
3698
+ return 0
3699
+
3700
+ # Check for updates (CLI commands only, not hooks)
3701
+ check_version_once_daily()
3702
+
3703
+ # Ensure directories exist
3704
+ if not ensure_hcom_directories():
3705
+ print(format_error("Failed to create HCOM directories"), file=sys.stderr)
3706
+ return 1
3707
+
3708
+ # Ensure hooks current (warns but never blocks)
3709
+ ensure_hooks_current()
3710
+
3711
+ # Route to commands
3712
+ try:
3713
+ if not argv or argv[0] in ('help', '--help', '-h'):
3060
3714
  return cmd_help()
3061
- case 'open':
3062
- return cmd_open(*argv[2:])
3063
- case 'watch':
3064
- return cmd_watch(*argv[2:])
3065
- case 'clear':
3066
- return cmd_clear()
3067
- case 'cleanup':
3068
- return cmd_cleanup(*argv[2:])
3069
- case 'send':
3070
- if len(argv) < 3:
3715
+ elif argv[0] == 'send_cli':
3716
+ if len(argv) < 2:
3071
3717
  print(format_error("Message required"), file=sys.stderr)
3072
3718
  return 1
3073
-
3074
- # HIDDEN COMMAND: --resume is only used internally by instances during resume workflow
3075
- # Not meant for regular CLI usage. Primary usage:
3076
- # - From instances: eval $HCOM send "message" (instances send messages to each other)
3077
- # - From CLI: hcom send "message" (user/claude orchestrator sends to instances)
3078
- if argv[2] == '--resume':
3079
- if len(argv) < 4:
3080
- print(format_error("Alias required for --resume"), file=sys.stderr)
3081
- return 1
3082
- return cmd_resume_merge(argv[3])
3083
-
3084
- return cmd_send(argv[2])
3085
- case 'kill':
3086
- return cmd_kill(*argv[2:])
3087
- case 'stop' | 'notify' | 'pre' | 'sessionstart' | 'userpromptsubmit':
3088
- handle_hook(cmd)
3089
- return 0
3090
- case _:
3091
- print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
3719
+ return send_cli(argv[1])
3720
+ elif argv[0] == 'watch':
3721
+ return cmd_watch(argv[1:])
3722
+ elif argv[0] == 'send':
3723
+ return cmd_send(argv[1:])
3724
+ elif argv[0] == 'stop':
3725
+ return cmd_stop(argv[1:])
3726
+ elif argv[0] == 'start':
3727
+ return cmd_start(argv[1:])
3728
+ elif argv[0] == 'reset':
3729
+ return cmd_reset(argv[1:])
3730
+ elif argv[0].isdigit() or argv[0] == 'claude':
3731
+ # Launch instances: hcom <1-100> [args] or hcom claude [args]
3732
+ return cmd_launch(argv)
3733
+ else:
3734
+ print(format_error(
3735
+ f"Unknown command: {argv[0]}",
3736
+ "Run 'hcom --help' for usage"
3737
+ ), file=sys.stderr)
3092
3738
  return 1
3739
+ except CLIError as exc:
3740
+ print(str(exc), file=sys.stderr)
3741
+ return 1
3093
3742
 
3094
3743
  if __name__ == '__main__':
3095
3744
  sys.exit(main())