hcom 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

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

Potentially problematic release.


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

hcom/__main__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- hcom 0.4.0
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
 
@@ -17,54 +17,22 @@ import time
17
17
  import select
18
18
  import platform
19
19
  import random
20
- import argparse
21
20
  from pathlib import Path
22
21
  from datetime import datetime, timedelta
23
- from typing import Any, NamedTuple, Sequence
24
- from dataclasses import dataclass, asdict, field
25
- from enum import Enum, auto
22
+ from typing import Any, Callable, NamedTuple, TextIO
23
+ from dataclasses import dataclass
26
24
 
27
25
  if sys.version_info < (3, 10):
28
26
  sys.exit("Error: hcom requires Python 3.10 or higher")
29
27
 
30
- __version__ = "0.4.0"
31
-
32
- # ==================== Session Scenario Types ====================
33
-
34
- class SessionScenario(Enum):
35
- """Explicit session startup scenarios for clear logic flow"""
36
- FRESH_START = auto() # New session, new instance
37
- MATCHED_RESUME = auto() # Resume with matching session_id (reuse instance)
38
- UNMATCHED_RESUME = auto() # Resume with no match (new instance, needs recovery)
39
-
40
- @dataclass
41
- class HookContext:
42
- """Consolidated context for hook handling with all decisions made"""
43
- instance_name: str
44
- updates: dict
45
- scenario: SessionScenario | None # None = deferred decision (SessionStart wrong session_id)
46
-
47
- @property
48
- def bypass_enabled_check(self) -> bool:
49
- """Unmatched resume needs critical message even if disabled"""
50
- return self.scenario == SessionScenario.UNMATCHED_RESUME
51
-
52
- @property
53
- def needs_critical_prompt(self) -> bool:
54
- """Should show critical recovery message?"""
55
- return self.scenario == SessionScenario.UNMATCHED_RESUME
56
-
57
- @property
58
- def is_resume(self) -> bool:
59
- """Is this any kind of resume?"""
60
- return self.scenario in (SessionScenario.MATCHED_RESUME, SessionScenario.UNMATCHED_RESUME)
28
+ __version__ = "0.5.0"
61
29
 
62
30
  # ==================== Constants ====================
63
31
 
64
32
  IS_WINDOWS = sys.platform == 'win32'
65
33
 
66
- def is_wsl():
67
- """Detect if running in WSL (Windows Subsystem for Linux)"""
34
+ def is_wsl() -> bool:
35
+ """Detect if running in WSL"""
68
36
  if platform.system() != 'Linux':
69
37
  return False
70
38
  try:
@@ -73,7 +41,7 @@ def is_wsl():
73
41
  except (FileNotFoundError, PermissionError, OSError):
74
42
  return False
75
43
 
76
- def is_termux():
44
+ def is_termux() -> bool:
77
45
  """Detect if running in Termux on Android"""
78
46
  return (
79
47
  'TERMUX_VERSION' in os.environ or # Primary: Works all versions
@@ -82,8 +50,6 @@ def is_termux():
82
50
  'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
83
51
  )
84
52
 
85
- EXIT_SUCCESS = 0
86
- EXIT_BLOCK = 2
87
53
 
88
54
  # Windows API constants
89
55
  CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
@@ -91,7 +57,6 @@ CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
91
57
  # Timing constants
92
58
  FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
93
59
  STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
94
- MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
95
60
 
96
61
  MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
97
62
  AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
@@ -148,95 +113,68 @@ if IS_WINDOWS or is_wsl():
148
113
  # ==================== Error Handling Strategy ====================
149
114
  # Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
150
115
  # CLI: Can raise exceptions for user feedback. Check return values.
151
- # Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
116
+ # Critical I/O: atomic_write, save_instance_position
152
117
  # Pattern: Try/except/return False in hooks, raise in CLI operations.
153
118
 
154
- # ==================== CLI Command Objects ====================
119
+ # ==================== CLI Errors ====================
155
120
 
156
121
  class CLIError(Exception):
157
122
  """Raised when arguments cannot be mapped to command semantics."""
158
123
 
159
- @dataclass
160
- class OpenCommand:
161
- count: int
162
- agents: list[str]
163
- prefix: str | None
164
- background: bool
165
- claude_args: list[str]
124
+ # ==================== Help Text ====================
166
125
 
167
- @dataclass
168
- class WatchCommand:
169
- mode: str # 'interactive', 'logs', 'status', 'wait'
170
- wait_seconds: int | None
126
+ HELP_TEXT = """hcom 0.5.0
171
127
 
172
- @dataclass
173
- class StopCommand:
174
- target: str | None
175
- close_all_hooks: bool
176
- force: bool
177
- _hcom_session: str | None = None # Injected by PreToolUse hook
128
+ Usage: [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
129
+ hcom watch [--logs|--status|--wait [SEC]]
130
+ hcom send "message"
131
+ hcom stop [alias|all] [--force]
132
+ hcom start [alias]
133
+ hcom reset [logs|hooks|config]
178
134
 
179
- @dataclass
180
- class StartCommand:
181
- target: str | None
182
- _hcom_session: str | None = None # Injected by PreToolUse hook
135
+ Launch Examples:
136
+ hcom 3 Open 3 terminals with claude connected to hcom
137
+ hcom 3 claude -p + Background/headless
138
+ HCOM_TAG=api hcom 3 claude -p + @-mention group tag
139
+ claude 'run hcom start' claude code with prompt will also work
183
140
 
184
- @dataclass
185
- class SendCommand:
186
- message: str | None
187
- resume_alias: str | None
188
- _hcom_session: str | None = None # Injected by PreToolUse hook
141
+ Commands:
142
+ watch Interactive messaging/status dashboard
143
+ --logs Print all messages
144
+ --status Print instance status JSON
145
+ --wait [SEC] Wait and notify for new message
189
146
 
190
- # ==================== Help Text ====================
147
+ send "msg" Send message to all instances
148
+ send "@alias msg" Send to specific instance/group
191
149
 
192
- HELP_TEXT = """hcom - Claude Hook Comms
150
+ stop Stop current instance (from inside Claude)
151
+ stop <alias> Stop specific instance
152
+ stop all Stop all instances
153
+ --force Emergency stop (denies Bash tool)
193
154
 
194
- Usage:
195
- hcom open [count] [-a agent]... [-t prefix] [-p] [-- claude-args]
196
- hcom watch [--logs|--status|--wait [SEC]]
197
- hcom stop [target] [--force]
198
- hcom start [target]
199
- hcom send "msg"
155
+ start Start current instance (from inside Claude)
156
+ start <alias> Start specific instance
157
+
158
+ reset Stop all + archive logs + remove hooks + clear config
159
+ reset logs Clear + archive conversation log
160
+ reset hooks Safely remove hcom hooks from claude settings.json
161
+ reset config Clear + backup config.env
162
+
163
+ Environment Variables:
164
+ HCOM_TAG=name Group tag (creates name-* instances)
165
+ HCOM_AGENT=type Agent type (comma-separated for multiple)
166
+ HCOM_TERMINAL=mode Terminal: new|here|print|"custom {script}"
167
+ HCOM_PROMPT=text "Say hi in hcom chat" (default)
168
+ HCOM_HINTS=text Text appended to all messages received by instance
169
+ HCOM_TIMEOUT=secs Time until disconnected from hcom chat (default 1800s / 30mins)
170
+
171
+ Config: ~/.hcom/config.env
172
+ Docs: https://github.com/aannoo/claude-hook-comms"""
200
173
 
201
- Commands:
202
- open Launch Claude instances (default count: 1)
203
- watch Monitor conversation dashboard
204
- stop Stop instances, clear conversation, or remove hooks
205
- start Start stopped instances
206
- send Send message to instances
207
-
208
- Open options:
209
- [count] Number of instances per agent (default 1)
210
- -a, --agent AGENT Agent to launch (repeatable)
211
- -t, --prefix PREFIX Team prefix for names
212
- -p, --background Launch in background
213
-
214
- Stop targets:
215
- (no arg) Stop HCOM for current instance (when inside)
216
- <alias> Stop HCOM for specific instance
217
- all Stop all instances + clear & archive conversation
218
- hooking Remove hooks from current directory
219
- hooking --all Remove hooks from all tracked directories
220
- everything Stop all + clear conversation + remove all hooks
221
-
222
- Start targets:
223
- (no arg) Start HCOM for current instance (when inside)
224
- <alias> Start HCOM for specific instance
225
- hooking Install hooks in current directory
226
-
227
- Watch options:
228
- --logs Show message history
229
- --status Show instance status JSON
230
- --wait [SEC] Wait for new messages (default 60s)
231
-
232
- Stop flags:
233
- --force Force stop (deny Bash tool use)
234
-
235
- Docs: https://github.com/aannoo/claude-hook-comms#readme"""
236
174
 
237
175
  # ==================== Logging ====================
238
176
 
239
- def log_hook_error(hook_name: str, error: Exception | None = None):
177
+ def log_hook_error(hook_name: str, error: Exception | str | None = None) -> None:
240
178
  """Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
241
179
  import traceback
242
180
  try:
@@ -253,48 +191,68 @@ def log_hook_error(hook_name: str, error: Exception | None = None):
253
191
  pass # Silent failure in error logging
254
192
 
255
193
  # ==================== Config Defaults ====================
194
+ # Config precedence: env var > ~/.hcom/config.env > defaults
195
+ # All config via HcomConfig dataclass (timeout, terminal, prompt, hints, tag, agent)
256
196
 
257
- # Type definition for configuration
258
- @dataclass
259
- class HcomConfig:
260
- terminal_command: str | None = None
261
- terminal_mode: str = "new_window"
262
- initial_prompt: str = "Say hi in chat"
263
- sender_name: str = "bigboss"
264
- sender_emoji: str = "🐳"
265
- cli_hints: str = ""
266
- wait_timeout: int = 1800 # 30mins
267
- max_message_size: int = 1048576 # 1MB
268
- max_messages_per_delivery: int = 50
269
- first_use_text: str = "Essential, concise messages only, say hi in hcom chat now"
270
- instance_hints: str = ""
271
- env_overrides: dict = field(default_factory=dict)
272
- auto_watch: bool = True # Auto-launch watch dashboard after open
273
-
274
- DEFAULT_CONFIG = HcomConfig()
275
-
276
- _config = None
277
-
278
- # Generate env var mappings from dataclass fields (except env_overrides)
279
- HOOK_SETTINGS = {
280
- field: f"HCOM_{field.upper()}"
281
- for field in DEFAULT_CONFIG.__dataclass_fields__
282
- if field != 'env_overrides'
283
- }
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)
284
203
 
285
204
  # Path constants
286
205
  LOG_FILE = "hcom.log"
287
206
  INSTANCES_DIR = "instances"
288
- LOGS_DIR = "logs"
289
- SCRIPTS_DIR = "scripts"
290
- CONFIG_FILE = "config.json"
207
+ LOGS_DIR = ".tmp/logs"
208
+ SCRIPTS_DIR = ".tmp/scripts"
209
+ FLAGS_DIR = ".tmp/flags"
210
+ CONFIG_FILE = "config.env"
291
211
  ARCHIVE_DIR = "archive"
292
212
 
293
- # Hook type constants
294
- ACTIVE_HOOK_TYPES = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop', 'Notification', 'SessionEnd']
295
- LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES + ['PostToolUse'] # For backward compatibility cleanup
296
- HOOK_COMMANDS = ['sessionstart', 'userpromptsubmit', 'pre', 'poll', 'notify', 'sessionend']
297
- LEGACY_HOOK_COMMANDS = HOOK_COMMANDS + ['post']
213
+ # Hook configuration - single source of truth for setup_hooks() and verify_hooks_installed()
214
+ # Format: (hook_type, matcher, command_suffix, timeout)
215
+ # Command gets built as: hook_cmd_base + ' ' + command_suffix (e.g., '${HCOM} poll')
216
+ HOOK_CONFIGS = [
217
+ ('SessionStart', '', 'sessionstart', None),
218
+ ('UserPromptSubmit', '', 'userpromptsubmit', None),
219
+ ('PreToolUse', 'Bash', 'pre', None),
220
+ ('PostToolUse', 'Bash', 'post', None), # Match Bash only
221
+ ('Stop', '', 'poll', 86400), # Poll for messages (24hr max timeout)
222
+ ('Notification', '', 'notify', None),
223
+ ('SessionEnd', '', 'sessionend', None),
224
+ ]
225
+
226
+ # Derived from HOOK_CONFIGS - guaranteed to stay in sync
227
+ ACTIVE_HOOK_TYPES = [cfg[0] for cfg in HOOK_CONFIGS]
228
+ HOOK_COMMANDS = [cfg[2] for cfg in HOOK_CONFIGS]
229
+ LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES
230
+ LEGACY_HOOK_COMMANDS = HOOK_COMMANDS
231
+
232
+ # Hook removal patterns - used by _remove_hcom_hooks_from_settings()
233
+ # Dynamically build from LEGACY_HOOK_COMMANDS to match current and legacy hook formats
234
+ _HOOK_ARGS_PATTERN = '|'.join(LEGACY_HOOK_COMMANDS)
235
+ HCOM_HOOK_PATTERNS = [
236
+ re.compile(r'\$\{?HCOM'), # Current: Environment variable ${HCOM:-...}
237
+ re.compile(r'\bHCOM_ACTIVE.*hcom\.py'), # LEGACY: Unix HCOM_ACTIVE conditional
238
+ re.compile(r'IF\s+"%HCOM_ACTIVE%"'), # LEGACY: Windows HCOM_ACTIVE conditional
239
+ re.compile(rf'\bhcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: Direct hcom command
240
+ re.compile(rf'\buvx\s+hcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: uvx hcom command
241
+ re.compile(rf'hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: hcom.py with optional quote
242
+ re.compile(rf'["\'][^"\']*hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b(?=\s|$)'), # LEGACY: Quoted path
243
+ re.compile(r'sh\s+-c.*hcom'), # LEGACY: Shell wrapper
244
+ ]
245
+
246
+ # PreToolUse hook pattern - matches hcom commands for session_id injection and auto-approval
247
+ # - hcom send (any args)
248
+ # - hcom stop (no args) | hcom start (no args)
249
+ # - hcom help | hcom --help | hcom -h
250
+ # - hcom watch --status | hcom watch --launch | hcom watch --logs | hcom watch --wait
251
+ # Negative lookahead (?!\s+[-\w]) ensures stop/start not followed by arguments or flags
252
+ HCOM_COMMAND_PATTERN = re.compile(
253
+ r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+'
254
+ r'(?:send\b|(?:stop|start)(?!\s+[-\w])|(?:help|--help|-h)\b|watch\s+(?:--status|--launch|--logs|--wait)\b)'
255
+ )
298
256
 
299
257
  # ==================== File System Utilities ====================
300
258
 
@@ -312,7 +270,7 @@ def ensure_hcom_directories() -> bool:
312
270
  Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
313
271
  Returns True on success, False on failure."""
314
272
  try:
315
- for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, ARCHIVE_DIR]:
273
+ for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, FLAGS_DIR, ARCHIVE_DIR]:
316
274
  hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
317
275
  return True
318
276
  except (OSError, PermissionError):
@@ -351,7 +309,7 @@ def atomic_write(filepath: str | Path, content: str) -> bool:
351
309
 
352
310
  return False # All attempts exhausted
353
311
 
354
- def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, max_retries: int = 3) -> Any:
312
+ def read_file_with_retry(filepath: str | Path, read_func: Callable[[TextIO], Any], default: Any = None, max_retries: int = 3) -> Any:
355
313
  """Read file with retry logic for Windows file locking"""
356
314
  if not Path(filepath).exists():
357
315
  return default
@@ -374,10 +332,6 @@ def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, m
374
332
 
375
333
  return default
376
334
 
377
- # ==================== Outbox System (REMOVED) ====================
378
- # Identity via session_id injection in handle_pretooluse (line 3134)
379
- # PreToolUse hook injects --_hcom_session, commands use get_display_name() for resolution
380
-
381
335
  def get_instance_file(instance_name: str) -> Path:
382
336
  """Get path to instance's position file with path traversal protection"""
383
337
  # Sanitize instance name to prevent directory traversal
@@ -409,6 +363,18 @@ def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
409
363
  except (OSError, PermissionError, ValueError):
410
364
  return False
411
365
 
366
+ def get_claude_settings_path() -> Path:
367
+ """Get path to global Claude settings file"""
368
+ return Path.home() / '.claude' / 'settings.json'
369
+
370
+ def load_settings_json(settings_path: Path, default: Any = None) -> dict[str, Any] | None:
371
+ """Load and parse settings JSON file with retry logic"""
372
+ return read_file_with_retry(
373
+ settings_path,
374
+ lambda f: json.load(f),
375
+ default=default
376
+ )
377
+
412
378
  def load_all_positions() -> dict[str, dict[str, Any]]:
413
379
  """Load positions from all instance files"""
414
380
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -436,93 +402,297 @@ def clear_all_positions() -> None:
436
402
 
437
403
  # ==================== Configuration System ====================
438
404
 
439
- def get_cached_config():
440
- """Get cached configuration, loading if needed"""
441
- global _config
442
- if _config is None:
443
- _config = _load_config_from_file()
444
- return _config
405
+ @dataclass
406
+ class HcomConfig:
407
+ """HCOM configuration with validation. Load priority: env → file → defaults"""
408
+ timeout: int = 1800
409
+ terminal: str = 'new'
410
+ prompt: str = 'say hi in hcom chat'
411
+ hints: str = ''
412
+ tag: str = ''
413
+ agent: str = ''
414
+
415
+ def __post_init__(self):
416
+ """Validate configuration on construction"""
417
+ errors = self.validate()
418
+ if errors:
419
+ raise ValueError(f"Invalid config:\n" + "\n".join(f" - {e}" for e in errors))
420
+
421
+ def validate(self) -> list[str]:
422
+ """Validate all fields, return list of errors"""
423
+ errors = []
424
+
425
+ # Validate timeout
426
+ # Validate timeout (bool is subclass of int in Python, must check explicitly)
427
+ if isinstance(self.timeout, bool):
428
+ errors.append(f"timeout must be an integer, not boolean (got {self.timeout})")
429
+ elif not isinstance(self.timeout, int):
430
+ errors.append(f"timeout must be an integer, got {type(self.timeout).__name__}")
431
+ elif not 1 <= self.timeout <= 86400:
432
+ errors.append(f"timeout must be 1-86400 seconds (24 hours), got {self.timeout}")
433
+
434
+ # Validate terminal
435
+ if not isinstance(self.terminal, str):
436
+ errors.append(f"terminal must be a string, got {type(self.terminal).__name__}")
437
+ else:
438
+ valid_modes = ('new', 'here', 'print')
439
+ if self.terminal not in valid_modes and '{script}' not in self.terminal:
440
+ errors.append(
441
+ f"terminal must be one of {valid_modes} or custom command with {{script}}, "
442
+ f"got '{self.terminal}'"
443
+ )
445
444
 
446
- def _load_config_from_file() -> dict:
447
- """Load configuration from ~/.hcom/config.json"""
448
- config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
445
+ # Validate tag (only alphanumeric and hyphens - security: prevent log delimiter injection)
446
+ if not isinstance(self.tag, str):
447
+ errors.append(f"tag must be a string, got {type(self.tag).__name__}")
448
+ elif self.tag and not re.match(r'^[a-zA-Z0-9-]+$', self.tag):
449
+ errors.append("tag can only contain letters, numbers, and hyphens")
450
+
451
+ # Validate agent
452
+ if not isinstance(self.agent, str):
453
+ errors.append(f"agent must be a string, got {type(self.agent).__name__}")
454
+
455
+ return errors
456
+
457
+ @classmethod
458
+ def load(cls) -> 'HcomConfig':
459
+ """Load config with precedence: env var → file → defaults"""
460
+ # Ensure config file exists
461
+ config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
462
+ created_config = False
463
+ if not config_path.exists():
464
+ _write_default_config(config_path)
465
+ created_config = True
466
+
467
+ # Warn once if legacy config.json still exists when creating config.env
468
+ legacy_config = hcom_path('config.json')
469
+ if created_config and legacy_config.exists():
470
+ print(
471
+ format_error(
472
+ "Found legacy ~/.hcom/config.json; new config file is: ~/.hcom/config.env."
473
+ ),
474
+ file=sys.stderr,
475
+ )
476
+
477
+ # Parse config file once
478
+ file_config = _parse_env_file(config_path) if config_path.exists() else {}
479
+
480
+ def get_var(key: str) -> str | None:
481
+ """Get variable with precedence: env → file"""
482
+ if key in os.environ:
483
+ return os.environ[key]
484
+ if key in file_config:
485
+ return file_config[key]
486
+ return None
487
+
488
+ data = {}
489
+
490
+ # Load timeout (requires int conversion)
491
+ timeout_str = get_var('HCOM_TIMEOUT')
492
+ if timeout_str is not None:
493
+ try:
494
+ data['timeout'] = int(timeout_str)
495
+ except (ValueError, TypeError):
496
+ pass # Use default
497
+
498
+ # Load string values
499
+ terminal = get_var('HCOM_TERMINAL')
500
+ if terminal is not None:
501
+ data['terminal'] = terminal
502
+ prompt = get_var('HCOM_PROMPT')
503
+ if prompt is not None:
504
+ data['prompt'] = prompt
505
+ hints = get_var('HCOM_HINTS')
506
+ if hints is not None:
507
+ data['hints'] = hints
508
+ tag = get_var('HCOM_TAG')
509
+ if tag is not None:
510
+ data['tag'] = tag
511
+ agent = get_var('HCOM_AGENT')
512
+ if agent is not None:
513
+ data['agent'] = agent
514
+
515
+ return cls(**data) # Validation happens in __post_init__
516
+
517
+ def _parse_env_file(config_path: Path) -> dict[str, str]:
518
+ """Parse ENV file (KEY=VALUE format) with security validation"""
519
+ config = {}
449
520
 
450
- # Start with default config as dict
451
- config_dict = asdict(DEFAULT_CONFIG)
521
+ # Dangerous shell metacharacters that enable command injection
522
+ DANGEROUS_CHARS = ['`', '$', ';', '|', '&', '\n', '\r']
452
523
 
453
524
  try:
454
- if user_config := read_file_with_retry(
455
- config_path,
456
- lambda f: json.load(f),
457
- default=None
458
- ):
459
- # Merge user config into default config
460
- config_dict.update(user_config)
461
- elif not config_path.exists():
462
- # Write default config if file doesn't exist
463
- atomic_write(config_path, json.dumps(config_dict, indent=2))
464
- except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
465
- print("Warning: Cannot read config file, using defaults", file=sys.stderr)
466
- # config_dict already has defaults
467
-
468
- return config_dict
469
-
470
- def get_config_value(key: str, default: Any = None) -> Any:
471
- """Get config value with proper precedence:
472
- 1. Environment variable (if in HOOK_SETTINGS)
473
- 2. Config file
474
- 3. Default value
475
- """
476
- if key in HOOK_SETTINGS:
477
- env_var = HOOK_SETTINGS[key]
478
- if (env_value := os.environ.get(env_var)) is not None:
479
- # Type conversion based on key
480
- if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
481
- try:
482
- return int(env_value)
483
- except ValueError:
484
- # Invalid integer - fall through to config/default
485
- pass
486
- elif key == 'auto_watch':
487
- return env_value.lower() in ('true', '1', 'yes', 'on')
488
- else:
489
- # String values - return as-is
490
- return env_value
525
+ content = config_path.read_text(encoding='utf-8')
526
+ for line in content.splitlines():
527
+ line = line.strip()
528
+ if not line or line.startswith('#'):
529
+ continue
530
+ if '=' in line:
531
+ key, _, value = line.partition('=')
532
+ key = key.strip()
533
+ value = value.strip()
534
+
535
+ # Security: Validate HCOM_TERMINAL for command injection
536
+ if key == 'HCOM_TERMINAL':
537
+ if any(c in value for c in DANGEROUS_CHARS):
538
+ print(
539
+ f"Warning: Unsafe characters in HCOM_TERMINAL "
540
+ f"({', '.join(repr(c) for c in DANGEROUS_CHARS if c in value)}), "
541
+ f"ignoring custom terminal command",
542
+ file=sys.stderr
543
+ )
544
+ continue
545
+ # Additional check: custom commands must contain {script} placeholder
546
+ if value not in ('new', 'here', 'print') and '{script}' not in value:
547
+ print(
548
+ f"Warning: HCOM_TERMINAL custom command must include {{script}} placeholder, "
549
+ f"ignoring",
550
+ file=sys.stderr
551
+ )
552
+ continue
553
+
554
+ # Remove outer quotes only if they match
555
+ if len(value) >= 2:
556
+ if (value[0] == value[-1]) and value[0] in ('"', "'"):
557
+ value = value[1:-1]
558
+ if key:
559
+ config[key] = value
560
+ except (FileNotFoundError, PermissionError, UnicodeDecodeError):
561
+ pass
562
+ return config
563
+
564
+ def _write_default_config(config_path: Path) -> None:
565
+ """Write default config file with documentation"""
566
+ header = """# HCOM Configuration
567
+ #
568
+ # All HCOM_* settings (and any env var ie. Claude Code settings)
569
+ # can be set here or via environment variables.
570
+ # Environment variables override config file values.
571
+ #
572
+ # HCOM settings:
573
+ # HCOM_TIMEOUT - Instance Stop hook wait timeout in seconds (default: 1800)
574
+ # HCOM_TERMINAL - Terminal mode: "new", "here", "print", or custom command with {script}
575
+ # HCOM_PROMPT - Initial prompt for new instances (empty = no auto prompt)
576
+ # HCOM_HINTS - Text appended to all messages received by instances
577
+ # HCOM_TAG - Group tag for instances (creates tag-* instances)
578
+ # HCOM_AGENT - Claude code subagent from .claude/agents/, comma-separated for multiple
579
+ #
580
+ # Put each value on separate lines without comments.
581
+ #
582
+ #
583
+ """
584
+ defaults = [
585
+ 'HCOM_TIMEOUT=1800',
586
+ 'HCOM_TERMINAL=new',
587
+ 'HCOM_PROMPT=say hi in hcom chat',
588
+ 'HCOM_HINTS=',
589
+ 'HCOM_TAG=',
590
+ 'HCOM_AGENT=',
591
+ ]
592
+ try:
593
+ atomic_write(config_path, header + '\n'.join(defaults) + '\n')
594
+ except Exception:
595
+ pass
491
596
 
492
- config = get_cached_config()
493
- return config.get(key, default)
597
+ # Global config instance (cached)
598
+ _config: HcomConfig | None = None
494
599
 
495
- def get_hook_command():
600
+ def get_config() -> HcomConfig:
601
+ """Get cached config, loading if needed"""
602
+ global _config
603
+ if _config is None:
604
+ _config = HcomConfig.load()
605
+ return _config
606
+
607
+ def _build_quoted_invocation() -> str:
608
+ """Build properly quoted python + script path for current platform"""
609
+ python_path = sys.executable
610
+ script_path = str(Path(__file__).resolve())
611
+
612
+ if IS_WINDOWS:
613
+ if ' ' in python_path or ' ' in script_path:
614
+ return f'"{python_path}" "{script_path}"'
615
+ return f'{python_path} {script_path}'
616
+ else:
617
+ return f'{shlex.quote(python_path)} {shlex.quote(script_path)}'
618
+
619
+ def get_hook_command() -> tuple[str, dict[str, Any]]:
496
620
  """Get hook command - hooks always run, Python code gates participation
497
621
 
498
622
  Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
499
623
  Participation is controlled by enabled flag in instance JSON files.
500
624
  """
501
- python_path = sys.executable
502
- script_path = str(Path(__file__).resolve())
503
-
504
625
  if IS_WINDOWS:
505
626
  # Windows: use python path directly
506
- if ' ' in python_path or ' ' in script_path:
507
- return f'"{python_path}" "{script_path}"', {}
508
- return f'{python_path} {script_path}', {}
627
+ return _build_quoted_invocation(), {}
509
628
  else:
510
- # Unix: Use HCOM env var from settings.local.json
629
+ # Unix: Use HCOM env var from settings.json
511
630
  return '${HCOM}', {}
512
631
 
513
632
  def _detect_hcom_command_type() -> str:
514
- """Detect how to invoke hcom (priority: hcom > uvx if running via uvx > full)"""
515
- if shutil.which('hcom'):
516
- return 'short'
517
- elif 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
633
+ """Detect how to invoke hcom based on execution context
634
+ Priority:
635
+ 1. uvx - If running in uv-managed Python and uvx available
636
+ (works for both temporary uvx runs and permanent uv tool install)
637
+ 2. short - If hcom binary in PATH
638
+ 3. full - Fallback to full python invocation
639
+ """
640
+ if 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
518
641
  return 'uvx'
642
+ elif shutil.which('hcom'):
643
+ return 'short'
519
644
  else:
520
645
  return 'full'
521
646
 
522
- def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
523
- """Build send command - caches PATH check in instance file on first use"""
524
- msg = f" '{example_msg}'" if example_msg else ''
647
+ def _parse_version(v: str) -> tuple:
648
+ """Parse version string to comparable tuple"""
649
+ return tuple(int(x) for x in v.split('.') if x.isdigit())
650
+
651
+ def get_update_notice() -> str | None:
652
+ """Check PyPI for updates (once daily), return message if available"""
653
+ flag = hcom_path(FLAGS_DIR, 'update_available')
654
+
655
+ # Check PyPI if flag missing or >24hrs old
656
+ should_check = not flag.exists() or time.time() - flag.stat().st_mtime > 86400
657
+
658
+ if should_check:
659
+ try:
660
+ import urllib.request
661
+ with urllib.request.urlopen('https://pypi.org/pypi/hcom/json', timeout=2) as f:
662
+ latest = json.load(f)['info']['version']
663
+
664
+ if _parse_version(latest) > _parse_version(__version__):
665
+ atomic_write(flag, latest) # mtime = cache timestamp
666
+ else:
667
+ flag.unlink(missing_ok=True)
668
+ return None
669
+ except Exception:
670
+ pass # Network error, use cached value if exists
671
+
672
+ # Return message if update available
673
+ if not flag.exists():
674
+ return None
525
675
 
676
+ try:
677
+ latest = flag.read_text().strip()
678
+ # Double-check version (handles manual upgrades)
679
+ if _parse_version(__version__) >= _parse_version(latest):
680
+ flag.unlink(missing_ok=True)
681
+ return None
682
+
683
+ cmd = "uv tool upgrade hcom" if _detect_hcom_command_type() == 'uvx' else "pip install -U hcom"
684
+ return f"→ hcom v{latest} available: {cmd}"
685
+ except Exception:
686
+ return None
687
+
688
+ def _build_hcom_env_value() -> str:
689
+ """Build the value for settings['env']['HCOM'] based on current execution context
690
+ Uses build_hcom_command() without caching for fresh detection on every call.
691
+ """
692
+ return build_hcom_command(None)
693
+
694
+ def build_hcom_command(instance_name: str | None = None) -> str:
695
+ """Build base hcom command - caches PATH check in instance file on first use"""
526
696
  # Determine command type (cached or detect)
527
697
  cmd_type = None
528
698
  if instance_name:
@@ -540,32 +710,40 @@ def build_send_command(example_msg: str = '', instance_name: str | None = None)
540
710
 
541
711
  # Build command based on type
542
712
  if cmd_type == 'short':
543
- return f'hcom send{msg}'
713
+ return 'hcom'
544
714
  elif cmd_type == 'uvx':
545
- return f'uvx hcom send{msg}'
715
+ return 'uvx hcom'
546
716
  else:
547
- python_path = shlex.quote(sys.executable)
548
- script_path = shlex.quote(str(Path(__file__).resolve()))
549
- return f'{python_path} {script_path} send{msg}'
717
+ # Full path fallback
718
+ return _build_quoted_invocation()
550
719
 
551
- def build_claude_env():
552
- """Build environment variables for Claude instances!"""
553
- env = {}
720
+ def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
721
+ """Build send command - caches PATH check in instance file on first use"""
722
+ msg = f" '{example_msg}'" if example_msg else ''
723
+ base_cmd = build_hcom_command(instance_name)
724
+ return f'{base_cmd} send{msg}'
554
725
 
555
- # Get config file values
556
- config = get_cached_config()
726
+ def build_claude_env() -> dict[str, str]:
727
+ """Build environment variables for Claude instances
557
728
 
558
- # Pass env vars only when they differ from config file values
559
- for config_key, env_var in HOOK_SETTINGS.items():
560
- actual_value = get_config_value(config_key) # Respects env var precedence
561
- config_file_value = config.get(config_key)
729
+ Passes current environment to Claude, with config.env providing defaults.
730
+ HCOM_* variables are filtered out (consumed by hcom, not passed to Claude).
731
+ """
732
+ env = {}
562
733
 
563
- # Only pass if different from config file (not default)
564
- if actual_value != config_file_value and actual_value is not None:
565
- env[env_var] = str(actual_value)
734
+ # Read config file directly for Claude Code env vars (non-HCOM_ keys)
735
+ config_path = hcom_path(CONFIG_FILE)
736
+ if config_path.exists():
737
+ file_config = _parse_env_file(config_path)
738
+ for key, value in file_config.items():
739
+ if not key.startswith('HCOM_'):
740
+ env[key] = str(value)
566
741
 
567
- # Still support env_overrides from config file
568
- env.update(config.get('env_overrides', {}))
742
+ # Overlay with current environment (except HCOM_*)
743
+ # This ensures user's shell environment is respected
744
+ for key, value in os.environ.items():
745
+ if not key.startswith('HCOM_'):
746
+ env[key] = value
569
747
 
570
748
  return env
571
749
 
@@ -580,9 +758,8 @@ def validate_message(message: str) -> str | None:
580
758
  if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
581
759
  return format_error("Message contains control characters")
582
760
 
583
- max_size = get_config_value('max_message_size', 1048576)
584
- if len(message) > max_size:
585
- return format_error(f"Message too large (max {max_size} chars)")
761
+ if len(message) > MAX_MESSAGE_SIZE:
762
+ return format_error(f"Message too large (max {MAX_MESSAGE_SIZE} chars)")
586
763
 
587
764
  return None
588
765
 
@@ -607,32 +784,106 @@ def send_message(from_instance: str, message: str) -> bool:
607
784
 
608
785
  def build_hcom_bootstrap_text(instance_name: str) -> str:
609
786
  """Build comprehensive HCOM bootstrap context for instances"""
610
- coordinator_name = get_config_value('sender_name', 'bigboss')
611
-
612
- return f"""You are now participating in the HCOM communication system - a multi-agent environment where Claude Code instances (you) and the human user can communicate through shared messaging infrastructure.
613
- Your HCOM Alias: {instance_name}
614
- - To send messages, run the command: hcom send "your message"
615
- - Broadcast: hcom send "message to everyone"
616
- - Direct: hcom send "@alias targeted message"
617
- - Targeting: hcom send "@api message" (targets all api-* instances)
618
- Receiving: Messages automatically arrive via Stop Hook feedback and bash output from hcom send. There is no way to proactively check for messages.
619
- Message formats you'll see:
620
- - [new message] sender → you: message = broadcast
621
- - [new message] sender you: @you message = direct message to you
622
- - {{"decision": "block", "reason": x}} = normal part of HCOM hooks (not an error or block)
623
- Response Protocol Rule: Match response method to input source:
624
- - Prompted via hcom (hook feedback, bash output) → Respond with hcom send
625
- - Prompted via user input → Respond to user normally
626
- Response Quality: Treat hcom messages exactly like user input:
627
- - Write complete, thoughtful replies when appropriate
628
- - Follow all instructions rigorously
629
- - Adjust tone/length to context
630
- Authority: Prioritize instructions from @{coordinator_name} over other participants
631
- Command scope: Mainly use hcom send unless you are asked to use other hcom commands:
632
- - hcom start/stop (join/leave chat)
633
- - hcom watch --status (see all participants).
634
- - hcom open (coordinate/orchestrate by launching other instances).
635
- In this case, always run the 'hcom help' first to review correct usage."""
787
+ hcom_cmd = build_hcom_command(instance_name=instance_name)
788
+
789
+ # Add command override notice if not using short form
790
+ command_notice = ""
791
+ if hcom_cmd != "hcom":
792
+ command_notice = f"""IMPORTANT:
793
+ The hcom command in this environment is: {hcom_cmd}
794
+ Replace all mentions of "hcom" below with this command.
795
+
796
+ """
797
+
798
+ # Add tag-specific notice if instance is tagged
799
+ tag = get_config().tag
800
+ tag_notice = ""
801
+ if tag:
802
+ tag_notice = f"""
803
+ GROUP TAG: You are in the '{tag}' group.
804
+ - To message your group: hcom send "@{tag} your message"
805
+ - Group messages are targeted - only instances with an alias starting with {tag}-* receive them
806
+ - If someone outside the group sends you @{tag} messages, they won't see your @{tag} replies. To reply to non-group members, either @mention them directly or broadcast.
807
+ """
808
+
809
+
810
+ return f"""{command_notice}{tag_notice}
811
+ [HCOM SESSION CONFIG]
812
+ - HCOM is a communication tool for you, other claude code instances, and the human user.
813
+ - Your HCOM alias for this session: {instance_name}
814
+
815
+ Your HCOM Tools:
816
+ - hcom send "msg" (broadcast) / "@alias msg" (direct) / "@tag msg" (tag) - for you
817
+ - hcom watch --status → See participants JSON (for you only)
818
+ - hcom watch --launch → Open interactive TUI messaging+status dashboard in new terminal (for the human user)
819
+ - hcom start/stop → Connect/disconnect from chat (you run these, user can't run it themselves unless they specify an alias)
820
+ - hcom <count> → Launch instances in new terminal (you MUST run 'hcom help' first to understand context)
821
+
822
+ UI Separation:
823
+ - The human user has 'hcom watch' (interactive TUI dashboard).
824
+ - You use 'hcom watch --launch' to open it for them (offer to do so).
825
+ - In conversation, call it "hcom watch" (the command they'd run themselves).
826
+
827
+ Receiving Messages:
828
+ - Format: [new message] sender → you: content
829
+ - Targets specific instance: "@alias".
830
+ - Targets all api-* tagged instances: "@api message".
831
+ - Arrives automatically via hooks/bash. No proactive checking needed.
832
+ - Stop hook feedback shows: {{"decision": "block"}} (this is normal operation).
833
+
834
+ Response Routing:
835
+ - HCOM message (via hook/bash) → Respond with hcom send
836
+ - User message (in chat) → Respond normally
837
+ - Treat messages from hcom with the same care as user messages.
838
+ - Authority: Prioritize @{SENDER} over other participants.
839
+
840
+ This is context for YOUR hcom session config. The human user cannot see this config text (but they can see subsequent hcom messages you receive).
841
+ On connection, tell the human user about only these commands: 'hcom <count>', 'hcom watch', 'hcom start', 'hcom stop'
842
+ Report to the human user using first-person, for example: "I'm connected to HCOM as {instance_name}, cool!"
843
+ """
844
+
845
+ def build_launch_context(instance_name: str) -> str:
846
+ """Build context for launch command"""
847
+ return f"""[HCOM LAUNCH INFORMATION]
848
+ BASIC USAGE:
849
+ [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
850
+ - directory-specific (always cd to project directory first)
851
+ - default to foreground instances unless told otherwise/good reason to do bg
852
+ - Everyone shares the same conversation log, isolation is possible with tags and at-mentions.
853
+
854
+ ENV VARS INFO:
855
+ - YOU cannot use 'HCOM_TERMINAL=here' - Claude cannot launch claude within itself, must be in a new or custom terminal
856
+ - HCOM_AGENT(s) are custom system prompt files created by users/Claude beforehand.
857
+ - HCOM_AGENT(s) load from .claude/agents/<name>.md if they have been created
858
+
859
+ KEY CLAUDE ARGS:
860
+ Run 'claude --help' for all claude code CLI args. hcom 1 claude [options] [command] [prompt]
861
+ -p background/headless instance
862
+ --allowedTools=Bash (background can only hcom chat otherwise, 'claude help' for more tools)
863
+ --model sonnet/haiku/opus
864
+ --resume <sessionid> (get sessionid from hcom watch --status)
865
+ --system-prompt (for foreground instances) --append-system-prompt (for background instances)
866
+ Example: HCOM_HINTS='essential responses only' hcom 2 claude --model sonnet -p "do task x"
867
+
868
+ CONTROL:
869
+ hcom watch --status JSON status of all instances
870
+ hcom watch --logs All messages (pipe to tail)
871
+ hcom watch --wait Block until next message (only use when hcom stopped (started is automatic already))
872
+
873
+ STATUS INDICATORS:
874
+ "active", "delivered" | "idle" - waiting for new messages
875
+ "blocked" - permission request (needs user approval)
876
+ "inactive" - timed out, disconnected etc
877
+ "unknown" / "stale" - could be dead
878
+
879
+ LAUNCH PATTERNS:
880
+ - HCOM_AGENT=reviewer,tester hcom 2 claude "do task x" # 2x reviewers + 2x testers (4 in total) with initial prompt
881
+ - clone with same context:
882
+ 1. hcom 1 then hcom send 'analyze api' then hcom watch --status (get sessionid)
883
+ 2. HCOM_TAG=clone hcom 3 claude --resume sessionid
884
+ - System prompt (or agent file) + initial prompt + hcom_hints is a powerful combination.
885
+
886
+ """
636
887
 
637
888
  def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
638
889
  """Check if message should be delivered based on @-mentions"""
@@ -653,8 +904,7 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
653
904
  return True
654
905
 
655
906
  # Check if any mention is for the CLI sender (bigboss)
656
- sender_name = get_config_value('sender_name', 'bigboss')
657
- sender_mentioned = any(sender_name.lower().startswith(mention.lower()) for mention in mentions)
907
+ sender_mentioned = any(SENDER.lower().startswith(mention.lower()) for mention in mentions)
658
908
 
659
909
  # If we have all_instance_names, check if ANY mention matches ANY instance or sender
660
910
  if all_instance_names:
@@ -698,11 +948,9 @@ def extract_agent_config(content: str) -> dict[str, str]:
698
948
 
699
949
  def resolve_agent(name: str) -> tuple[str, dict[str, str]]:
700
950
  """Resolve agent file by name with validation.
701
-
702
951
  Looks for agent files in:
703
952
  1. .claude/agents/{name}.md (local)
704
953
  2. ~/.claude/agents/{name}.md (global)
705
-
706
954
  Returns tuple: (content without YAML frontmatter, config dict)
707
955
  """
708
956
  hint = 'Agent names must use lowercase letters and dashes only'
@@ -775,59 +1023,84 @@ def strip_frontmatter(content: str) -> str:
775
1023
  return '\n'.join(lines[i+1:]).strip()
776
1024
  return content
777
1025
 
778
- def get_display_name(session_id: str | None, prefix: str | None = None) -> str:
1026
+ def get_display_name(session_id: str | None, tag: str | None = None) -> str:
779
1027
  """Get display name for instance using session_id"""
780
- 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']
781
- # Phonetic letters (5 per syllable, matches syls order)
782
- phonetic = "nrlstnrlstnrlstnrlstnrlstnrlstnmlstnmlstnrlmtnrlmtnrlmsnrlmsnrlstnrlstnrlmtnrlmtnrlaynrlaynrlaynrlayaanxrtanxrtdtraxntdaxntraxnrdaynrlaynrlasnrlst"
783
-
784
- dir_char = (Path.cwd().name + 'x')[0].lower()
1028
+ # 50 most recognizable 3-letter words
1029
+ words = [
1030
+ 'ace', 'air', 'ant', 'arm', 'art', 'axe', 'bad', 'bag', 'bar', 'bat',
1031
+ 'bed', 'bee', 'big', 'box', 'boy', 'bug', 'bus', 'cab', 'can', 'cap',
1032
+ 'car', 'cat', 'cop', 'cow', 'cry', 'cup', 'cut', 'day', 'dog', 'dry',
1033
+ 'ear', 'egg', 'eye', 'fan', 'fin', 'fly', 'fox', 'fun', 'gem', 'gun',
1034
+ 'hat', 'hit', 'hot', 'ice', 'ink', 'jet', 'key', 'law', 'map', 'mix',
1035
+ ]
785
1036
 
786
1037
  # Use session_id directly instead of extracting UUID from transcript
787
1038
  if session_id:
1039
+ # Hash to select word
788
1040
  hash_val = sum(ord(c) for c in session_id)
789
- syl_idx = hash_val % len(syls)
790
- syllable = syls[syl_idx]
1041
+ word = words[hash_val % len(words)]
1042
+
1043
+ # Add letter suffix that flows naturally with the word
1044
+ last_char = word[-1]
1045
+ if last_char in 'aeiou':
1046
+ # After vowel: s/n/r/l creates plural/noun/verb patterns
1047
+ suffix_options = 'snrl'
1048
+ else:
1049
+ # After consonant: add vowel or y for pronounceability
1050
+ suffix_options = 'aeiouy'
791
1051
 
792
- letters = phonetic[syl_idx * 5:(syl_idx + 1) * 5]
793
1052
  letter_hash = sum(ord(c) for c in session_id[1:]) if len(session_id) > 1 else hash_val
794
- letter = letters[letter_hash % 5]
1053
+ suffix = suffix_options[letter_hash % len(suffix_options)]
795
1054
 
796
- # Session IDs are UUIDs like "374acbe2-978b-4882-9c0b-641890f066e1"
797
- hex_char = session_id[0] if session_id else 'x'
798
- base_name = f"{dir_char}{syllable}{letter}{hex_char}"
1055
+ base_name = f"{word}{suffix}"
1056
+ collision_attempt = 0
1057
+
1058
+ # Collision detection: keep adding words until unique
1059
+ while True:
1060
+ instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
1061
+ if not instance_file.exists():
1062
+ break # Name is unique
799
1063
 
800
- # Collision detection: if taken by different session_id, use more chars
801
- instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
802
- if instance_file.exists():
803
1064
  try:
804
1065
  with open(instance_file, 'r', encoding='utf-8') as f:
805
1066
  data = json.load(f)
806
1067
 
807
1068
  their_session_id = data.get('session_id', '')
808
1069
 
809
- # Deterministic check: different session_id = collision
810
- if their_session_id and their_session_id != session_id:
811
- # Use first 4 chars of session_id for collision resolution
812
- base_name = f"{dir_char}{session_id[0:4]}"
813
- # If same session_id, it's our file - reuse the name (no collision)
814
- # If no session_id in file, assume it's stale/malformed - use base name
1070
+ # Same session_id = our file, reuse name
1071
+ if their_session_id == session_id:
1072
+ break
1073
+ # No session_id = stale/malformed file, use name
1074
+ if not their_session_id:
1075
+ break
1076
+
1077
+ # Real collision - add another word
1078
+ collision_hash = sum(ord(c) * (i + collision_attempt) for i, c in enumerate(session_id))
1079
+ collision_word = words[collision_hash % len(words)]
1080
+ base_name = f"{base_name}{collision_word}"
1081
+ collision_attempt += 1
815
1082
 
816
1083
  except (json.JSONDecodeError, KeyError, ValueError, OSError):
817
- pass # Malformed file - assume stale, use base name
1084
+ break # Malformed file - assume stale, use base name
818
1085
  else:
819
1086
  # session_id is required - fail gracefully
820
1087
  raise ValueError("session_id required for instance naming")
821
1088
 
822
- if prefix:
823
- return f"{prefix}-{base_name}"
1089
+ if tag:
1090
+ # Security: Sanitize tag to prevent log delimiter injection (defense-in-depth)
1091
+ # Remove dangerous characters that could break message log parsing
1092
+ sanitized_tag = ''.join(c for c in tag if c not in '|\n\r\t')
1093
+ if not sanitized_tag:
1094
+ raise ValueError("Tag contains only invalid characters")
1095
+ if sanitized_tag != tag:
1096
+ print(f"Warning: Tag contained invalid characters, sanitized to '{sanitized_tag}'", file=sys.stderr)
1097
+ return f"{sanitized_tag}-{base_name}"
824
1098
  return base_name
825
1099
 
826
- def resolve_instance_name(session_id: str, prefix: str | None = None) -> tuple[str, dict | None]:
1100
+ def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str, dict | None]:
827
1101
  """
828
1102
  Resolve instance name for a session_id.
829
1103
  Searches existing instances first (reuses if found), generates new name if not found.
830
-
831
1104
  Returns: (instance_name, existing_data_or_none)
832
1105
  """
833
1106
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -843,36 +1116,18 @@ def resolve_instance_name(session_id: str, prefix: str | None = None) -> tuple[s
843
1116
  continue
844
1117
 
845
1118
  # Not found - generate new name
846
- instance_name = get_display_name(session_id, prefix)
1119
+ instance_name = get_display_name(session_id, tag)
847
1120
  return instance_name, None
848
1121
 
849
- def _remove_hcom_hooks_from_settings(settings):
1122
+ def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
850
1123
  """Remove hcom hooks from settings dict"""
851
1124
  if not isinstance(settings, dict) or 'hooks' not in settings:
852
1125
  return
853
-
1126
+
854
1127
  if not isinstance(settings['hooks'], dict):
855
1128
  return
856
-
857
- import copy
858
1129
 
859
- # Build regex patterns dynamically from LEGACY_HOOK_COMMANDS
860
- # Current hooks (pattern 1): ${HCOM:-...} environment variable
861
- # Legacy hooks (patterns 2-7): Older formats that need cleanup
862
- # - HCOM_ACTIVE conditionals (removed for toggle implementation)
863
- # - Direct command invocation with specific hook args
864
- hook_args_pattern = '|'.join(LEGACY_HOOK_COMMANDS)
865
- hcom_patterns = [
866
- r'\$\{?HCOM', # Current: Environment variable ${HCOM:-...}
867
- r'\bHCOM_ACTIVE.*hcom\.py', # LEGACY: Unix HCOM_ACTIVE conditional
868
- r'IF\s+"%HCOM_ACTIVE%"', # LEGACY: Windows HCOM_ACTIVE conditional
869
- rf'\bhcom\s+({hook_args_pattern})\b', # LEGACY: Direct hcom command
870
- rf'\buvx\s+hcom\s+({hook_args_pattern})\b', # LEGACY: uvx hcom command
871
- rf'hcom\.py["\']?\s+({hook_args_pattern})\b', # LEGACY: hcom.py with optional quote
872
- rf'["\'][^"\']*hcom\.py["\']?\s+({hook_args_pattern})\b(?=\s|$)', # LEGACY: Quoted path
873
- r'sh\s+-c.*hcom', # LEGACY: Shell wrapper
874
- ]
875
- compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
1130
+ import copy
876
1131
 
877
1132
  # Check all hook types including PostToolUse for backward compatibility cleanup
878
1133
  for event in LEGACY_HOOK_TYPES:
@@ -885,7 +1140,11 @@ def _remove_hcom_hooks_from_settings(settings):
885
1140
  # Fail fast on malformed settings - Claude won't run with broken settings anyway
886
1141
  if not isinstance(matcher, dict):
887
1142
  raise ValueError(f"Malformed settings: matcher in {event} is not a dict: {type(matcher).__name__}")
888
-
1143
+
1144
+ # Validate hooks field if present
1145
+ if 'hooks' in matcher and not isinstance(matcher['hooks'], list):
1146
+ raise ValueError(f"Malformed settings: hooks in {event} matcher is not a list: {type(matcher['hooks']).__name__}")
1147
+
889
1148
  # Work with a copy to avoid any potential reference issues
890
1149
  matcher_copy = copy.deepcopy(matcher)
891
1150
 
@@ -894,7 +1153,7 @@ def _remove_hcom_hooks_from_settings(settings):
894
1153
  hook for hook in matcher_copy.get('hooks', [])
895
1154
  if not any(
896
1155
  pattern.search(hook.get('command', ''))
897
- for pattern in compiled_patterns
1156
+ for pattern in HCOM_HOOK_PATTERNS
898
1157
  )
899
1158
  ]
900
1159
 
@@ -902,7 +1161,8 @@ def _remove_hcom_hooks_from_settings(settings):
902
1161
  if non_hcom_hooks:
903
1162
  matcher_copy['hooks'] = non_hcom_hooks
904
1163
  updated_matchers.append(matcher_copy)
905
- elif not matcher.get('hooks'): # Preserve matchers that never had hooks
1164
+ elif 'hooks' not in matcher or matcher['hooks'] == []:
1165
+ # Preserve matchers that never had hooks (missing key or empty list only)
906
1166
  updated_matchers.append(matcher_copy)
907
1167
 
908
1168
  # Update or remove the event
@@ -919,7 +1179,7 @@ def _remove_hcom_hooks_from_settings(settings):
919
1179
  del settings['env']
920
1180
 
921
1181
 
922
- def build_env_string(env_vars, format_type="bash"):
1182
+ def build_env_string(env_vars: dict[str, Any], format_type: str = "bash") -> str:
923
1183
  """Build environment variable string for bash shells"""
924
1184
  if format_type == "bash_export":
925
1185
  # Properly escape values for bash
@@ -936,12 +1196,12 @@ def format_error(message: str, suggestion: str | None = None) -> str:
936
1196
  return base
937
1197
 
938
1198
 
939
- def has_claude_arg(claude_args, arg_names, arg_prefixes):
1199
+ def has_claude_arg(claude_args: list[str] | None, arg_names: list[str], arg_prefixes: tuple[str, ...]) -> bool:
940
1200
  """Check if argument already exists in claude_args"""
941
- return claude_args and any(
1201
+ return bool(claude_args and any(
942
1202
  arg in arg_names or arg.startswith(arg_prefixes)
943
1203
  for arg in claude_args
944
- )
1204
+ ))
945
1205
 
946
1206
  def build_claude_command(agent_content: str | None = None, claude_args: list[str] | None = None, initial_prompt: str = "Say hi in chat", model: str | None = None, tools: str | None = None) -> tuple[str, str | None]:
947
1207
  """Build Claude command with proper argument handling
@@ -982,21 +1242,19 @@ def build_claude_command(agent_content: str | None = None, claude_args: list[str
982
1242
 
983
1243
  cmd_parts.append(flag)
984
1244
  cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
985
-
986
- if claude_args or agent_content:
987
- cmd_parts.append('--')
988
-
989
- # Quote initial prompt normally
990
- cmd_parts.append(shlex.quote(initial_prompt))
991
-
1245
+
1246
+ # Add initial prompt if non-empty
1247
+ if initial_prompt:
1248
+ cmd_parts.append(shlex.quote(initial_prompt))
1249
+
992
1250
  return ' '.join(cmd_parts), temp_file_path
993
1251
 
994
- def create_bash_script(script_file, env, cwd, command_str, background=False):
1252
+ def create_bash_script(script_file: str, env: dict[str, Any], cwd: str | None, command_str: str, background: bool = False) -> None:
995
1253
  """Create a bash script for terminal launch
996
1254
  Scripts provide uniform execution across all platforms/terminals.
997
1255
  Cleanup behavior:
998
1256
  - Normal scripts: append 'rm -f' command for self-deletion
999
- - Background scripts: persist until stop housekeeping (e.g., `hcom stop everything`) (24 hours)
1257
+ - Background scripts: persist until `hcom reset logs` cleanup (24 hours)
1000
1258
  - Agent scripts: treated like background (contain 'hcom_agent_')
1001
1259
  """
1002
1260
  try:
@@ -1064,11 +1322,10 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
1064
1322
  if platform.system() != 'Windows':
1065
1323
  os.chmod(script_file, 0o755)
1066
1324
 
1067
- def find_bash_on_windows():
1325
+ def find_bash_on_windows() -> str | None:
1068
1326
  """Find Git Bash on Windows, avoiding WSL's bash launcher"""
1069
1327
  # Build prioritized list of bash candidates
1070
1328
  candidates = []
1071
-
1072
1329
  # 1. Common Git Bash locations (highest priority)
1073
1330
  for base in [os.environ.get('PROGRAMFILES', r'C:\Program Files'),
1074
1331
  os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
@@ -1077,7 +1334,6 @@ def find_bash_on_windows():
1077
1334
  str(Path(base) / 'Git' / 'usr' / 'bin' / 'bash.exe'), # usr/bin is more common
1078
1335
  str(Path(base) / 'Git' / 'bin' / 'bash.exe')
1079
1336
  ])
1080
-
1081
1337
  # 2. Portable Git installation
1082
1338
  if local_appdata := os.environ.get('LOCALAPPDATA', ''):
1083
1339
  git_portable = Path(local_appdata) / 'Programs' / 'Git'
@@ -1085,11 +1341,9 @@ def find_bash_on_windows():
1085
1341
  str(git_portable / 'usr' / 'bin' / 'bash.exe'),
1086
1342
  str(git_portable / 'bin' / 'bash.exe')
1087
1343
  ])
1088
-
1089
1344
  # 3. PATH bash (if not WSL's launcher)
1090
1345
  if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
1091
1346
  candidates.append(path_bash)
1092
-
1093
1347
  # 4. Hardcoded fallbacks (last resort)
1094
1348
  candidates.extend([
1095
1349
  r'C:\Program Files\Git\usr\bin\bash.exe',
@@ -1097,7 +1351,6 @@ def find_bash_on_windows():
1097
1351
  r'C:\Program Files (x86)\Git\usr\bin\bash.exe',
1098
1352
  r'C:\Program Files (x86)\Git\bin\bash.exe'
1099
1353
  ])
1100
-
1101
1354
  # Find first existing bash
1102
1355
  for bash in candidates:
1103
1356
  if bash and Path(bash).exists():
@@ -1106,11 +1359,11 @@ def find_bash_on_windows():
1106
1359
  return None
1107
1360
 
1108
1361
  # New helper functions for platform-specific terminal launching
1109
- def get_macos_terminal_argv():
1362
+ def get_macos_terminal_argv() -> list[str]:
1110
1363
  """Return macOS Terminal.app launch command as argv list."""
1111
1364
  return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
1112
1365
 
1113
- def get_windows_terminal_argv():
1366
+ def get_windows_terminal_argv() -> list[str]:
1114
1367
  """Return Windows terminal launcher as argv list."""
1115
1368
  if not (bash_exe := find_bash_on_windows()):
1116
1369
  raise Exception(format_error("Git Bash not found"))
@@ -1119,7 +1372,7 @@ def get_windows_terminal_argv():
1119
1372
  return ['wt', bash_exe, '{script}']
1120
1373
  return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
1121
1374
 
1122
- def get_linux_terminal_argv():
1375
+ def get_linux_terminal_argv() -> list[str] | None:
1123
1376
  """Return first available Linux terminal as argv list."""
1124
1377
  terminals = [
1125
1378
  ('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
@@ -1138,7 +1391,7 @@ def get_linux_terminal_argv():
1138
1391
 
1139
1392
  return None
1140
1393
 
1141
- def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
1394
+ def windows_hidden_popen(argv: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None, stdout: Any = None) -> subprocess.Popen:
1142
1395
  """Create hidden Windows process without console window."""
1143
1396
  if IS_WINDOWS:
1144
1397
  startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
@@ -1165,7 +1418,7 @@ PLATFORM_TERMINAL_GETTERS = {
1165
1418
  'Linux': get_linux_terminal_argv,
1166
1419
  }
1167
1420
 
1168
- def _parse_terminal_command(template, script_file):
1421
+ def _parse_terminal_command(template: str, script_file: str) -> list[str]:
1169
1422
  """Parse terminal command template safely to prevent shell injection.
1170
1423
  Parses the template FIRST, then replaces {script} placeholder in the
1171
1424
  parsed tokens. This avoids shell injection and handles paths with spaces.
@@ -1203,7 +1456,7 @@ def _parse_terminal_command(template, script_file):
1203
1456
 
1204
1457
  return replaced
1205
1458
 
1206
- def launch_terminal(command, env, cwd=None, background=False):
1459
+ def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, background: bool = False) -> str | bool | None:
1207
1460
  """Launch terminal with command using unified script-first approach
1208
1461
  Args:
1209
1462
  command: Command string from build_claude_command
@@ -1265,9 +1518,9 @@ def launch_terminal(command, env, cwd=None, background=False):
1265
1518
  return str(log_file)
1266
1519
 
1267
1520
  # 3) Terminal modes
1268
- terminal_mode = get_config_value('terminal_mode', 'new_window')
1521
+ terminal_mode = get_config().terminal
1269
1522
 
1270
- if terminal_mode == 'show_commands':
1523
+ if terminal_mode == 'print':
1271
1524
  # Print script path and contents
1272
1525
  try:
1273
1526
  with open(script_file, 'r', encoding='utf-8') as f:
@@ -1280,7 +1533,7 @@ def launch_terminal(command, env, cwd=None, background=False):
1280
1533
  print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
1281
1534
  return False
1282
1535
 
1283
- if terminal_mode == 'same_terminal':
1536
+ if terminal_mode == 'here':
1284
1537
  print("Launching Claude in current terminal...")
1285
1538
  if IS_WINDOWS:
1286
1539
  bash_exe = find_bash_on_windows()
@@ -1292,10 +1545,11 @@ def launch_terminal(command, env, cwd=None, background=False):
1292
1545
  result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
1293
1546
  return result.returncode == 0
1294
1547
 
1295
- # 4) New window mode
1296
- custom_cmd = get_config_value('terminal_command')
1548
+ # 4) New window or custom command mode
1549
+ # If terminal is not 'here' or 'print', it's either 'new' (platform default) or a custom command
1550
+ custom_cmd = None if terminal_mode == 'new' else terminal_mode
1297
1551
 
1298
- if not custom_cmd: # No string sentinel checks
1552
+ if not custom_cmd: # Platform default 'new' mode
1299
1553
  if is_termux():
1300
1554
  # Keep Termux as special case
1301
1555
  am_cmd = [
@@ -1361,18 +1615,30 @@ def launch_terminal(command, env, cwd=None, background=False):
1361
1615
  print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
1362
1616
  return False
1363
1617
 
1364
- def setup_hooks():
1365
- """Set up Claude hooks in current directory"""
1366
- claude_dir = Path.cwd() / '.claude'
1367
- claude_dir.mkdir(exist_ok=True)
1368
-
1369
- settings_path = claude_dir / 'settings.local.json'
1618
+ def setup_hooks() -> bool:
1619
+ """Set up Claude hooks globally in ~/.claude/settings.json"""
1620
+
1621
+ # TODO: Remove after v0.6.0 - cleanup legacy per-directory hooks
1370
1622
  try:
1371
- settings = read_file_with_retry(
1372
- settings_path,
1373
- lambda f: json.load(f),
1374
- default={}
1375
- )
1623
+ positions = load_all_positions()
1624
+ if positions:
1625
+ directories = set()
1626
+ for instance_data in positions.values():
1627
+ if isinstance(instance_data, dict) and 'directory' in instance_data:
1628
+ directories.add(instance_data['directory'])
1629
+ for directory in directories:
1630
+ if Path(directory).exists():
1631
+ cleanup_directory_hooks(Path(directory))
1632
+ except Exception:
1633
+ pass # Don't fail hook setup if cleanup fails
1634
+
1635
+ # Install to global user settings
1636
+ settings_path = get_claude_settings_path()
1637
+ settings_path.parent.mkdir(exist_ok=True)
1638
+ try:
1639
+ settings = load_settings_json(settings_path, default={})
1640
+ if settings is None:
1641
+ settings = {}
1376
1642
  except (json.JSONDecodeError, PermissionError) as e:
1377
1643
  raise Exception(format_error(f"Cannot read settings: {e}"))
1378
1644
 
@@ -1384,24 +1650,12 @@ def setup_hooks():
1384
1650
  # Get the hook command template
1385
1651
  hook_cmd_base, _ = get_hook_command()
1386
1652
 
1387
- # Define all hooks - must match ACTIVE_HOOK_TYPES
1388
- # Format: (hook_type, matcher, command, timeout)
1653
+ # Build hook commands from HOOK_CONFIGS
1389
1654
  hook_configs = [
1390
- ('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
1391
- ('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
1392
- ('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
1393
- ('Stop', '', f'{hook_cmd_base} poll', 86400), # 24hr timeout max; internal timeout 30min default via config
1394
- ('Notification', '', f'{hook_cmd_base} notify', None),
1395
- ('SessionEnd', '', f'{hook_cmd_base} sessionend', None),
1655
+ (hook_type, matcher, f'{hook_cmd_base} {cmd_suffix}', timeout)
1656
+ for hook_type, matcher, cmd_suffix, timeout in HOOK_CONFIGS
1396
1657
  ]
1397
1658
 
1398
- # Validate hook_configs matches ACTIVE_HOOK_TYPES
1399
- configured_types = [hook_type for hook_type, _, _, _ in hook_configs]
1400
- if configured_types != ACTIVE_HOOK_TYPES:
1401
- raise Exception(format_error(
1402
- f"Hook configuration mismatch: {configured_types} != {ACTIVE_HOOK_TYPES}"
1403
- ))
1404
-
1405
1659
  for hook_type, matcher, command, timeout in hook_configs:
1406
1660
  if hook_type not in settings['hooks']:
1407
1661
  settings['hooks'][hook_type] = []
@@ -1422,9 +1676,8 @@ def setup_hooks():
1422
1676
  if 'env' not in settings:
1423
1677
  settings['env'] = {}
1424
1678
 
1425
- python_path = sys.executable
1426
- script_path = str(Path(__file__).resolve())
1427
- settings['env']['HCOM'] = f'{python_path} {script_path}'
1679
+ # Set HCOM based on current execution context (uvx, hcom binary, or full path)
1680
+ settings['env']['HCOM'] = _build_hcom_env_value()
1428
1681
 
1429
1682
  # Write settings atomically
1430
1683
  try:
@@ -1438,37 +1691,32 @@ def setup_hooks():
1438
1691
 
1439
1692
  return True
1440
1693
 
1441
- def verify_hooks_installed(settings_path):
1694
+ def verify_hooks_installed(settings_path: Path) -> bool:
1442
1695
  """Verify that HCOM hooks were installed correctly with correct commands"""
1443
1696
  try:
1444
- settings = read_file_with_retry(
1445
- settings_path,
1446
- lambda f: json.load(f),
1447
- default=None
1448
- )
1697
+ settings = load_settings_json(settings_path, default=None)
1449
1698
  if not settings:
1450
1699
  return False
1451
1700
 
1452
- # Check all hook types have correct commands
1701
+ # Check all hook types have correct commands (exactly one HCOM hook per type)
1702
+ # Derive from HOOK_CONFIGS (single source of truth)
1453
1703
  hooks = settings.get('hooks', {})
1454
- for hook_type, expected_cmd in zip(ACTIVE_HOOK_TYPES, HOOK_COMMANDS):
1704
+ for hook_type, _, cmd_suffix, _ in HOOK_CONFIGS:
1455
1705
  hook_matchers = hooks.get(hook_type, [])
1456
1706
  if not hook_matchers:
1457
1707
  return False
1458
1708
 
1459
- # Check if any matcher has the correct command
1460
- found_correct_cmd = False
1709
+ # Count HCOM hooks for this type
1710
+ hcom_hook_count = 0
1461
1711
  for matcher in hook_matchers:
1462
1712
  for hook in matcher.get('hooks', []):
1463
1713
  command = hook.get('command', '')
1464
1714
  # Check for HCOM and the correct subcommand
1465
- if ('${HCOM}' in command or 'hcom' in command.lower()) and expected_cmd in command:
1466
- found_correct_cmd = True
1467
- break
1468
- if found_correct_cmd:
1469
- break
1715
+ if ('${HCOM}' in command or 'hcom' in command.lower()) and cmd_suffix in command:
1716
+ hcom_hook_count += 1
1470
1717
 
1471
- if not found_correct_cmd:
1718
+ # Must have exactly one HCOM hook (not zero, not duplicates)
1719
+ if hcom_hook_count != 1:
1472
1720
  return False
1473
1721
 
1474
1722
  # Check that HCOM env var is set
@@ -1480,11 +1728,11 @@ def verify_hooks_installed(settings_path):
1480
1728
  except Exception:
1481
1729
  return False
1482
1730
 
1483
- def is_interactive():
1731
+ def is_interactive() -> bool:
1484
1732
  """Check if running in interactive mode"""
1485
1733
  return sys.stdin.isatty() and sys.stdout.isatty()
1486
1734
 
1487
- def get_archive_timestamp():
1735
+ def get_archive_timestamp() -> str:
1488
1736
  """Get timestamp for archive files"""
1489
1737
  return datetime.now().strftime("%Y-%m-%d_%H%M%S")
1490
1738
 
@@ -1602,17 +1850,24 @@ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
1602
1850
 
1603
1851
  # Check timeout
1604
1852
  age = now - last_status_time
1605
- timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1853
+ timeout = pos_data.get('wait_timeout', get_config().timeout)
1606
1854
  if age > timeout:
1607
1855
  return "inactive", "", "timeout"
1608
1856
 
1857
+ # Check Stop hook heartbeat for both blocked-generic and waiting-stale detection
1858
+ last_stop = pos_data.get('last_stop', 0)
1859
+ heartbeat_age = now - last_stop if last_stop else 999999
1860
+
1861
+ # Generic "Claude is waiting for your input" from Notification hook is meaningless
1862
+ # If Stop hook is actively polling (heartbeat < 2s), instance is actually idle
1863
+ if last_status == 'blocked' and last_context == "Claude is waiting for your input" and heartbeat_age < 2:
1864
+ last_status = 'waiting'
1865
+ display_status, desc_template = 'waiting', 'idle'
1866
+
1609
1867
  # Detect stale 'waiting' status - check heartbeat, not status timestamp
1610
- if last_status == 'waiting':
1611
- last_stop = pos_data.get('last_stop', 0)
1612
- heartbeat_age = now - last_stop if last_stop else 999999
1613
- if heartbeat_age > 2:
1614
- status_suffix = " (bg)" if pos_data.get('background') else ""
1615
- return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
1868
+ if last_status == 'waiting' and heartbeat_age > 2:
1869
+ status_suffix = " (bg)" if pos_data.get('background') else ""
1870
+ return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
1616
1871
 
1617
1872
  # Format description with context if template has {}
1618
1873
  if '{}' in desc_template and last_context:
@@ -1629,15 +1884,12 @@ def get_status_block(status_type: str) -> str:
1629
1884
  text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
1630
1885
  return f"{text_color}{BOLD}{color} {symbol} {RESET}"
1631
1886
 
1632
- def format_message_line(msg, truncate=False):
1887
+ def format_message_line(msg: dict[str, str], truncate: bool = False) -> str:
1633
1888
  """Format a message for display"""
1634
1889
  time_obj = datetime.fromisoformat(msg['timestamp'])
1635
1890
  time_str = time_obj.strftime("%H:%M")
1636
-
1637
- sender_name = get_config_value('sender_name', 'bigboss')
1638
- sender_emoji = get_config_value('sender_emoji', '🐳')
1639
-
1640
- display_name = f"{sender_emoji} {msg['from']}" if msg['from'] == sender_name else msg['from']
1891
+
1892
+ display_name = f"{SENDER_EMOJI} {msg['from']}" if msg['from'] == SENDER else msg['from']
1641
1893
 
1642
1894
  if truncate:
1643
1895
  sender = display_name[:10]
@@ -1646,7 +1898,7 @@ def format_message_line(msg, truncate=False):
1646
1898
  else:
1647
1899
  return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
1648
1900
 
1649
- def show_recent_messages(messages, limit=None, truncate=False):
1901
+ def show_recent_messages(messages: list[dict[str, str]], limit: int | None = None, truncate: bool = False) -> None:
1650
1902
  """Show recent messages"""
1651
1903
  if limit is None:
1652
1904
  messages_to_show = messages
@@ -1658,14 +1910,14 @@ def show_recent_messages(messages, limit=None, truncate=False):
1658
1910
  print(format_message_line(msg, truncate))
1659
1911
 
1660
1912
 
1661
- def get_terminal_height():
1913
+ def get_terminal_height() -> int:
1662
1914
  """Get current terminal height"""
1663
1915
  try:
1664
1916
  return shutil.get_terminal_size().lines
1665
1917
  except (AttributeError, OSError):
1666
1918
  return 24
1667
1919
 
1668
- def show_recent_activity_alt_screen(limit=None):
1920
+ def show_recent_activity_alt_screen(limit: int | None = None) -> None:
1669
1921
  """Show recent messages in alt screen format with dynamic height"""
1670
1922
  if limit is None:
1671
1923
  # Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
@@ -1677,7 +1929,7 @@ def show_recent_activity_alt_screen(limit=None):
1677
1929
  messages = parse_log_messages(log_file).messages
1678
1930
  show_recent_messages(messages, limit, truncate=True)
1679
1931
 
1680
- def should_show_in_watch(d):
1932
+ def should_show_in_watch(d: dict[str, Any]) -> bool:
1681
1933
  """Show only enabled instances by default"""
1682
1934
  # Hide disabled instances
1683
1935
  if not d.get('enabled', False):
@@ -1690,7 +1942,7 @@ def should_show_in_watch(d):
1690
1942
  # Show all other instances (including 'closed' during transition)
1691
1943
  return True
1692
1944
 
1693
- def show_instances_by_directory():
1945
+ def show_instances_by_directory() -> None:
1694
1946
  """Show instances organized by their working directories"""
1695
1947
  positions = load_all_positions()
1696
1948
  if not positions:
@@ -1747,7 +1999,7 @@ def alt_screen_detailed_status_and_input() -> str:
1747
1999
 
1748
2000
  return message
1749
2001
 
1750
- def get_status_summary():
2002
+ def get_status_summary() -> str:
1751
2003
  """Get a one-line summary of all instance statuses"""
1752
2004
  positions = load_all_positions()
1753
2005
  if not positions:
@@ -1780,18 +2032,18 @@ def get_status_summary():
1780
2032
  else:
1781
2033
  return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1782
2034
 
1783
- def update_status(s):
2035
+ def update_status(s: str) -> None:
1784
2036
  """Update status line in place"""
1785
2037
  sys.stdout.write("\r\033[K" + s)
1786
2038
  sys.stdout.flush()
1787
2039
 
1788
- def log_line_with_status(message, status):
2040
+ def log_line_with_status(message: str, status: str) -> None:
1789
2041
  """Print message and immediately restore status"""
1790
2042
  sys.stdout.write("\r\033[K" + message + "\n")
1791
2043
  sys.stdout.write("\033[K" + status)
1792
2044
  sys.stdout.flush()
1793
2045
 
1794
- def initialize_instance_in_position_file(instance_name, session_id=None):
2046
+ def initialize_instance_in_position_file(instance_name: str, session_id: str | None = None) -> bool:
1795
2047
  """Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
1796
2048
  try:
1797
2049
  data = load_instance_position(instance_name)
@@ -1799,15 +2051,23 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
1799
2051
  # Determine default enabled state: True for hcom-launched, False for vanilla
1800
2052
  is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
1801
2053
 
2054
+ # Determine starting position: skip history or read from beginning (or last max_msgs num)
2055
+ initial_pos = 0
2056
+ if SKIP_HISTORY:
2057
+ log_file = hcom_path(LOG_FILE)
2058
+ if log_file.exists():
2059
+ initial_pos = log_file.stat().st_size
2060
+
1802
2061
  defaults = {
1803
- "pos": 0,
2062
+ "pos": initial_pos,
1804
2063
  "enabled": is_hcom_launched,
1805
2064
  "directory": str(Path.cwd()),
1806
2065
  "last_stop": 0,
1807
2066
  "session_id": session_id or "",
1808
2067
  "transcript_path": "",
1809
2068
  "notification_message": "",
1810
- "alias_announced": False
2069
+ "alias_announced": False,
2070
+ "tag": None
1811
2071
  }
1812
2072
 
1813
2073
  # Add missing fields (preserve existing)
@@ -1818,7 +2078,7 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
1818
2078
  except Exception:
1819
2079
  return False
1820
2080
 
1821
- def update_instance_position(instance_name, update_fields):
2081
+ def update_instance_position(instance_name: str, update_fields: dict[str, Any]) -> None:
1822
2082
  """Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
1823
2083
  try:
1824
2084
  data = load_instance_position(instance_name)
@@ -1837,7 +2097,7 @@ def update_instance_position(instance_name, update_fields):
1837
2097
  else:
1838
2098
  raise
1839
2099
 
1840
- def enable_instance(instance_name):
2100
+ def enable_instance(instance_name: str) -> None:
1841
2101
  """Enable instance - clears all stop flags and enables Stop hook polling"""
1842
2102
  update_instance_position(instance_name, {
1843
2103
  'enabled': True,
@@ -1846,14 +2106,13 @@ def enable_instance(instance_name):
1846
2106
  })
1847
2107
  set_status(instance_name, 'started')
1848
2108
 
1849
- def disable_instance(instance_name, force=False):
2109
+ def disable_instance(instance_name: str, force: bool = False) -> None:
1850
2110
  """Disable instance - stops Stop hook polling"""
1851
2111
  updates = {
1852
2112
  'enabled': False
1853
2113
  }
1854
2114
  if force:
1855
2115
  updates['force_closed'] = True
1856
-
1857
2116
  update_instance_position(instance_name, updates)
1858
2117
  set_status(instance_name, 'force_stopped' if force else 'stopped')
1859
2118
 
@@ -1864,82 +2123,14 @@ def set_status(instance_name: str, status: str, context: str = ''):
1864
2123
  'last_status_time': int(time.time()),
1865
2124
  'last_status_context': context
1866
2125
  })
1867
-
1868
- def merge_instance_data(to_data, from_data):
1869
- """Merge instance data from from_data into to_data."""
1870
- # Use current session_id from source (overwrites previous)
1871
- to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
1872
-
1873
- # Update transient fields from source
1874
- to_data['transcript_path'] = from_data.get('transcript_path', to_data.get('transcript_path', ''))
1875
-
1876
- # Preserve maximum position
1877
- to_data['pos'] = max(to_data.get('pos', 0), from_data.get('pos', 0))
1878
-
1879
- # Update directory to most recent
1880
- to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
1881
-
1882
- # Update heartbeat timestamp to most recent
1883
- to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
1884
-
1885
- # Merge new status fields - take most recent status event
1886
- from_time = from_data.get('last_status_time', 0)
1887
- to_time = to_data.get('last_status_time', 0)
1888
- if from_time > to_time:
1889
- to_data['last_status'] = from_data.get('last_status', '')
1890
- to_data['last_status_time'] = from_time
1891
- to_data['last_status_context'] = from_data.get('last_status_context', '')
1892
-
1893
- # Preserve background mode if set
1894
- to_data['background'] = to_data.get('background') or from_data.get('background')
1895
- if from_data.get('background_log_file'):
1896
- to_data['background_log_file'] = from_data['background_log_file']
1897
-
1898
- return to_data
1899
-
1900
- def merge_instance_immediately(from_name, to_name):
1901
- """Merge from_name into to_name with safety checks. Returns success message or error message."""
1902
- if from_name == to_name:
1903
- return ""
1904
-
1905
- try:
1906
- from_data = load_instance_position(from_name)
1907
- to_data = load_instance_position(to_name)
1908
-
1909
- # Check if target has recent activity (time-based check instead of PID)
1910
- now = time.time()
1911
- last_activity = max(
1912
- to_data.get('last_stop', 0),
1913
- to_data.get('last_status_time', 0)
1914
- )
1915
- time_since_activity = now - last_activity
1916
- if time_since_activity < MERGE_ACTIVITY_THRESHOLD:
1917
- return f"Cannot recover {to_name}: instance is active (activity {int(time_since_activity)}s ago)"
1918
-
1919
- # Merge data using helper
1920
- to_data = merge_instance_data(to_data, from_data)
1921
-
1922
- # Save merged data - check for success
1923
- if not save_instance_position(to_name, to_data):
1924
- return f"Failed to save merged data for {to_name}"
1925
-
1926
- # Cleanup source file only after successful save
1927
- try:
1928
- hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
1929
- except (FileNotFoundError, PermissionError, OSError):
1930
- pass # Non-critical if cleanup fails
1931
-
1932
- return f"[SUCCESS] ✓ Recovered alias: {to_name}"
1933
- except Exception:
1934
- return f"Failed to recover alias: {to_name}"
1935
-
2126
+ log_hook_error('set_status', f'Setting status to {status} with context {context} for {instance_name}')
1936
2127
 
1937
2128
  # ==================== Command Functions ====================
1938
2129
 
1939
- def show_main_screen_header():
2130
+ def show_main_screen_header() -> list[dict[str, str]]:
1940
2131
  """Show header for main screen"""
1941
2132
  sys.stdout.write("\033[2J\033[H")
1942
-
2133
+
1943
2134
  log_file = hcom_path(LOG_FILE)
1944
2135
  all_messages = []
1945
2136
  if log_file.exists():
@@ -1950,279 +2141,64 @@ def show_main_screen_header():
1950
2141
 
1951
2142
  return all_messages
1952
2143
 
1953
- def show_cli_hints(to_stderr=True):
1954
- """Show CLI hints if configured"""
1955
- cli_hints = get_config_value('cli_hints', '')
1956
- if cli_hints:
1957
- if to_stderr:
1958
- print(f"\n{cli_hints}", file=sys.stderr)
1959
- else:
1960
- print(f"\n{cli_hints}")
1961
-
1962
- # ==================== CLI Parsing Functions ====================
1963
-
1964
- def parse_count(value: str) -> int:
1965
- """Parse and validate instance count"""
1966
- try:
1967
- number = int(value, 10)
1968
- except ValueError as exc:
1969
- raise argparse.ArgumentTypeError('Count must be an integer. Use -a/--agent for agent names.') from exc
1970
- if number <= 0:
1971
- raise argparse.ArgumentTypeError('Count must be positive.')
1972
- if number > 100:
1973
- raise argparse.ArgumentTypeError('Too many instances requested (max 100).')
1974
- return number
1975
-
1976
- def split_forwarded_args(argv: Sequence[str]) -> tuple[list[str], list[str]]:
1977
- """Split arguments on -- separator for forwarding to claude"""
1978
- if '--' not in argv:
1979
- return list(argv), []
1980
- idx = argv.index('--')
1981
- return list(argv[:idx]), list(argv[idx + 1:])
1982
-
1983
- def parse_open(namespace: argparse.Namespace, forwarded: list[str]) -> OpenCommand:
1984
- """Parse and validate open command arguments"""
1985
- prefix = namespace.prefix
1986
- if prefix and '|' in prefix:
1987
- raise CLIError('Prefix cannot contain "|" characters.')
1988
-
1989
- agents = namespace.agent or []
1990
- count = namespace.count if namespace.count is not None else 1
1991
- if not agents:
1992
- agents = ['generic']
1993
-
1994
- return OpenCommand(
1995
- count=count,
1996
- agents=agents,
1997
- prefix=prefix,
1998
- background=namespace.background,
1999
- claude_args=forwarded,
2000
- )
2001
-
2002
- def parse_watch(namespace: argparse.Namespace) -> WatchCommand:
2003
- """Parse and validate watch command arguments"""
2004
- wait_value = namespace.wait
2005
- if wait_value is not None and wait_value < 0:
2006
- raise CLIError('--wait expects a non-negative number of seconds.')
2007
-
2008
- if wait_value is not None:
2009
- return WatchCommand(mode='wait', wait_seconds=wait_value or 60)
2010
- if namespace.logs:
2011
- return WatchCommand(mode='logs', wait_seconds=None)
2012
- if namespace.status:
2013
- return WatchCommand(mode='status', wait_seconds=None)
2014
- return WatchCommand(mode='interactive', wait_seconds=None)
2015
-
2016
- def parse_stop(namespace: argparse.Namespace) -> StopCommand:
2017
- """Parse and validate stop command arguments"""
2018
- target = namespace.target
2019
- return StopCommand(
2020
- target=target,
2021
- close_all_hooks=namespace.all,
2022
- force=namespace.force,
2023
- _hcom_session=getattr(namespace, '_hcom_session', None),
2024
- )
2025
-
2026
- def parse_start(namespace: argparse.Namespace) -> StartCommand:
2027
- """Parse and validate start command arguments"""
2028
- return StartCommand(
2029
- target=namespace.target,
2030
- _hcom_session=getattr(namespace, '_hcom_session', None),
2031
- )
2032
-
2033
- def parse_send(namespace: argparse.Namespace) -> SendCommand:
2034
- """Parse and validate send command arguments"""
2035
- if namespace.resume and namespace.message:
2036
- raise CLIError('Specify a resume alias or a message, not both.')
2037
- session_id = getattr(namespace, '_hcom_session', None)
2038
- if namespace.resume:
2039
- return SendCommand(message=None, resume_alias=namespace.resume, _hcom_session=session_id)
2040
- if namespace.message is None:
2041
- raise CLIError('Message required (usage: hcom send "message").')
2042
- return SendCommand(message=namespace.message, resume_alias=None, _hcom_session=session_id)
2043
-
2044
- def build_parser() -> argparse.ArgumentParser:
2045
- """Build argparse parser for hcom commands"""
2046
- parser = argparse.ArgumentParser(prog='hcom', add_help=False)
2047
- subparsers = parser.add_subparsers(dest='command', required=True)
2048
-
2049
- # Open command
2050
- open_parser = subparsers.add_parser('open', add_help=False)
2051
- open_parser.add_argument('count', nargs='?', type=parse_count, default=1)
2052
- open_parser.add_argument('-a', '--agent', dest='agent', action='append')
2053
- open_parser.add_argument('-t', '--prefix', dest='prefix')
2054
- open_parser.add_argument('-p', '--background', action='store_true', dest='background')
2055
- open_parser.add_argument('--help', action='store_true', dest='help_flag')
2056
- open_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2057
-
2058
- # Watch command
2059
- watch_parser = subparsers.add_parser('watch', add_help=False)
2060
- group = watch_parser.add_mutually_exclusive_group()
2061
- group.add_argument('--logs', action='store_true')
2062
- group.add_argument('--status', action='store_true')
2063
- group.add_argument('--wait', nargs='?', const=60, type=int, metavar='SEC')
2064
- watch_parser.add_argument('--help', action='store_true', dest='help_flag')
2065
- watch_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2066
-
2067
- # Stop command
2068
- stop_parser = subparsers.add_parser('stop', add_help=False)
2069
- stop_parser.add_argument('target', nargs='?')
2070
- stop_parser.add_argument('--all', action='store_true')
2071
- stop_parser.add_argument('--force', action='store_true')
2072
- stop_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
2073
- stop_parser.add_argument('--help', action='store_true', dest='help_flag')
2074
- stop_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2075
-
2076
- # Start command
2077
- start_parser = subparsers.add_parser('start', add_help=False)
2078
- start_parser.add_argument('target', nargs='?')
2079
- start_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
2080
- start_parser.add_argument('--help', action='store_true', dest='help_flag')
2081
- start_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2082
-
2083
- # Send command
2084
- send_parser = subparsers.add_parser('send', add_help=False)
2085
- send_parser.add_argument('message', nargs='?')
2086
- send_parser.add_argument('--resume', metavar='ALIAS', help=argparse.SUPPRESS)
2087
- send_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
2088
- send_parser.add_argument('--help', action='store_true', dest='help_flag')
2089
- send_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2090
-
2091
- return parser
2092
-
2093
- def dispatch(namespace: argparse.Namespace, forwarded: list[str]):
2094
- """Dispatch parsed arguments to appropriate command parser"""
2095
- command = namespace.command
2096
- if command == 'open':
2097
- if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2098
- return cmd_help()
2099
- return parse_open(namespace, forwarded)
2100
- if command == 'watch':
2101
- if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2102
- return cmd_help()
2103
- return parse_watch(namespace)
2104
- if command == 'stop':
2105
- if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2106
- return cmd_help()
2107
- return parse_stop(namespace)
2108
- if command == 'start':
2109
- if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2110
- return cmd_help()
2111
- return parse_start(namespace)
2112
- if command == 'send':
2113
- if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2114
- return cmd_help()
2115
- return parse_send(namespace)
2116
- raise CLIError(f'Unsupported command: {command}')
2117
-
2118
- def needs_help(args: Sequence[str]) -> bool:
2119
- """Check if help was requested"""
2120
- if not args:
2121
- return True
2122
- head = args[0]
2123
- if head in {'help', '--help', '-h'}:
2124
- return True
2125
- return False
2126
-
2127
- # ==================== Command Functions ====================
2128
-
2129
- def cmd_help():
2144
+ def cmd_help() -> int:
2130
2145
  """Show help text"""
2131
2146
  print(HELP_TEXT)
2132
-
2133
- # Additional help for AI assistants
2134
- if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
2135
- print("""
2136
-
2137
- === ADDITIONAL INFO ===
2138
-
2139
- CONCEPT: HCOM launches Claude Code instances in new terminal windows.
2140
- They communicate with each other via a shared conversation.
2141
- You communicate with them via hcom commands.
2142
-
2143
- KEY UNDERSTANDING:
2144
- • Single conversation - All instances share ~/.hcom/hcom.log
2145
- • Messaging - CLI and instances send with hcom send "message"
2146
- • Instances receive messages via hooks automatically
2147
- • hcom open is directory-specific - always cd to project directory first
2148
- • Named agents are custom system prompt files created by users/claude code beforehand.
2149
- • Named agents load from .claude/agents/<name>.md - if they have been created
2150
- • hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
2151
-
2152
- LAUNCH PATTERNS:
2153
- hcom open 2 # 2 generic instances
2154
- hcom open -a reviewer # 1 reviewer instance (agent file must already exist)
2155
- hcom open 3 -a reviewer # 3 reviewer instances
2156
- hcom open -a reviewer -a tester # 1 reviewer + 1 tester
2157
- hcom open -t api 2 # Team naming: api-hova7, api-kolec
2158
- hcom open -- --model sonnet # Pass `claude` CLI flags after --
2159
- hcom open -p # Detached background (stop with: hcom stop <alias>)
2160
- hcom open -- --resume <sessionid> # Resume specific session
2161
- HCOM_INITIAL_PROMPT="task" hcom open # Set initial prompt for instance
2162
-
2163
- @MENTION TARGETING:
2164
- hcom send "message" # Broadcasts to everyone
2165
- hcom send "@api fix this" # Targets all api-* instances (api-hova7, api-kolec)
2166
- hcom send "@hova7 status?" # Targets specific instance
2167
- (Unmatched @mentions broadcast to everyone)
2168
-
2169
- STATUS INDICATORS:
2170
- • ▶ active - processing/executing • ▷ delivered - instance just received a message
2171
- • ◉ idle - waiting for new messages • ■ blocked - permission request (needs user approval)
2172
- • ○ inactive - timed out, disconnected, etc • ○ unknown
2173
-
2174
- CONFIG:
2175
- Config file (persistent): ~/.hcom/config.json
2176
-
2177
- Key settings (full list in config.json):
2178
- terminal_mode: "new_window" (default) | "same_terminal" | "show_commands"
2179
- initial_prompt: "Say hi in chat", first_use_text: "Essential messages only..."
2180
- instance_hints: "text", cli_hints: "text" # Extra info for instances/CLI
2181
- env_overrides: "custom environment variables for instances"
2182
-
2183
- Temporary environment overrides for any setting (all caps & append HCOM_):
2184
- HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages received by instance
2185
- export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
2186
-
2187
- EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
2188
- with 'hcom watch --status'. Instances respond automatically in shared chat.
2189
-
2190
- Run 'claude --help' to see all claude code CLI flags.""")
2191
-
2192
- show_cli_hints(to_stderr=False)
2193
- else:
2194
- if not IS_WINDOWS:
2195
- print("\nFor additional info & examples: hcom --help | cat")
2196
-
2197
2147
  return 0
2198
2148
 
2199
- def cmd_open(command: OpenCommand):
2200
- """Launch Claude instances with chat enabled"""
2149
+ def cmd_launch(argv: list[str]) -> int:
2150
+ """Launch Claude instances: hcom [N] [claude] [args]"""
2201
2151
  try:
2152
+ # Parse arguments: hcom [N] [claude] [args]
2153
+ count = 1
2154
+ forwarded = []
2155
+
2156
+ # Extract count if first arg is digit
2157
+ if argv and argv[0].isdigit():
2158
+ count = int(argv[0])
2159
+ if count <= 0:
2160
+ raise CLIError('Count must be positive.')
2161
+ if count > 100:
2162
+ raise CLIError('Too many instances requested (max 100).')
2163
+ argv = argv[1:]
2164
+
2165
+ # Skip 'claude' keyword if present
2166
+ if argv and argv[0] == 'claude':
2167
+ argv = argv[1:]
2168
+
2169
+ # Forward all remaining args to claude CLI
2170
+ forwarded = argv
2171
+
2172
+ # Get tag from config
2173
+ tag = get_config().tag
2174
+ if tag and '|' in tag:
2175
+ raise CLIError('Tag cannot contain "|" characters.')
2176
+
2177
+ # Get agents from config (comma-separated)
2178
+ agent_env = get_config().agent
2179
+ agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['']
2180
+
2181
+ # Detect background mode from -p/--print flags in forwarded args
2182
+ background = '-p' in forwarded or '--print' in forwarded
2183
+
2202
2184
  # Add -p flag and stream-json output for background mode if not already present
2203
- claude_args = command.claude_args
2204
- if command.background and '-p' not in claude_args and '--print' not in claude_args:
2185
+ claude_args = forwarded
2186
+ if background and '-p' not in claude_args and '--print' not in claude_args:
2205
2187
  claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
2206
2188
 
2207
- terminal_mode = get_config_value('terminal_mode', 'new_window')
2189
+ terminal_mode = get_config().terminal
2208
2190
 
2209
2191
  # Calculate total instances to launch
2210
- total_instances = command.count * len(command.agents)
2192
+ total_instances = count * len(agents)
2211
2193
 
2212
- # Fail fast for same_terminal with multiple instances
2213
- if terminal_mode == 'same_terminal' and total_instances > 1:
2194
+ # Fail fast for here mode with multiple instances
2195
+ if terminal_mode == 'here' and total_instances > 1:
2214
2196
  print(format_error(
2215
- f"same_terminal mode cannot launch {total_instances} instances",
2216
- "Use 'hcom open' for one generic instance or 'hcom open -a <agent>' for one agent"
2197
+ f"'here' mode cannot launch {total_instances} instances (it's one terminal window)",
2198
+ "Use 'hcom 1' for one generic instance"
2217
2199
  ), file=sys.stderr)
2218
2200
  return 1
2219
2201
 
2220
- try:
2221
- setup_hooks()
2222
- except Exception as e:
2223
- print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
2224
- return 1
2225
-
2226
2202
  log_file = hcom_path(LOG_FILE)
2227
2203
  instances_dir = hcom_path(INSTANCES_DIR)
2228
2204
 
@@ -2232,21 +2208,16 @@ def cmd_open(command: OpenCommand):
2232
2208
  # Build environment variables for Claude instances
2233
2209
  base_env = build_claude_env()
2234
2210
 
2235
- # Add prefix-specific hints if provided
2236
- if command.prefix:
2237
- base_env['HCOM_PREFIX'] = command.prefix
2238
- send_cmd = build_send_command()
2239
- hint = f"To respond to {command.prefix} group: {send_cmd} '@{command.prefix} message'"
2240
- base_env['HCOM_INSTANCE_HINTS'] = hint
2241
- first_use = f"You're in the {command.prefix} group. Use {command.prefix} to message: {send_cmd} '@{command.prefix} message'"
2242
- base_env['HCOM_FIRST_USE_TEXT'] = first_use
2211
+ # Add tag-specific hints if provided
2212
+ if tag:
2213
+ base_env['HCOM_TAG'] = tag
2243
2214
 
2244
2215
  launched = 0
2245
- initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
2216
+ initial_prompt = get_config().prompt
2246
2217
 
2247
2218
  # Launch count instances of each agent
2248
- for agent in command.agents:
2249
- for _ in range(command.count):
2219
+ for agent in agents:
2220
+ for _ in range(count):
2250
2221
  instance_type = agent
2251
2222
  instance_env = base_env.copy()
2252
2223
 
@@ -2254,14 +2225,14 @@ def cmd_open(command: OpenCommand):
2254
2225
  instance_env['HCOM_LAUNCHED'] = '1'
2255
2226
 
2256
2227
  # Mark background instances via environment with log filename
2257
- if command.background:
2228
+ if background:
2258
2229
  # Generate unique log filename
2259
2230
  log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
2260
2231
  instance_env['HCOM_BACKGROUND'] = log_filename
2261
2232
 
2262
2233
  # Build claude command
2263
- if instance_type == 'generic':
2264
- # Generic instance - no agent content
2234
+ if not instance_type:
2235
+ # No agent - no agent content
2265
2236
  claude_cmd, _ = build_claude_command(
2266
2237
  agent_content=None,
2267
2238
  claude_args=claude_args,
@@ -2292,7 +2263,7 @@ def cmd_open(command: OpenCommand):
2292
2263
  continue
2293
2264
 
2294
2265
  try:
2295
- if command.background:
2266
+ if background:
2296
2267
  log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
2297
2268
  if log_file:
2298
2269
  print(f"Background instance launched, log: {log_file}")
@@ -2316,37 +2287,31 @@ def cmd_open(command: OpenCommand):
2316
2287
  else:
2317
2288
  print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
2318
2289
 
2319
- # Auto-launch watch dashboard if configured and conditions are met
2320
- terminal_mode = get_config_value('terminal_mode')
2321
- auto_watch = get_config_value('auto_watch', True)
2290
+ # Auto-launch watch dashboard if in new window mode (new or custom) and all instances launched successfully
2291
+ terminal_mode = get_config().terminal
2322
2292
 
2323
- # Only auto-watch if ALL instances launched successfully
2324
- if terminal_mode == 'new_window' and auto_watch and failed == 0 and is_interactive():
2293
+ # Only auto-watch if ALL instances launched successfully and launches windows (not 'here' or 'print')
2294
+ if terminal_mode not in ('here', 'print') and failed == 0 and is_interactive():
2325
2295
  # Show tips first if needed
2326
- if command.prefix:
2327
- print(f"\n • Send to {command.prefix} team: hcom send '@{command.prefix} message'")
2296
+ if tag:
2297
+ print(f"\n • Send to {tag} team: hcom send '@{tag} message'")
2328
2298
 
2329
2299
  # Clear transition message
2330
2300
  print("\nOpening hcom watch...")
2331
2301
  time.sleep(2) # Brief pause so user sees the message
2332
2302
 
2333
2303
  # Launch interactive watch dashboard in current terminal
2334
- watch_cmd = WatchCommand(mode='interactive', wait_seconds=None)
2335
- return cmd_watch(watch_cmd)
2304
+ return cmd_watch([]) # Empty argv = interactive mode
2336
2305
  else:
2337
2306
  tips = [
2338
2307
  "Run 'hcom watch' to view/send in conversation dashboard",
2339
2308
  ]
2340
- if command.prefix:
2341
- tips.append(f"Send to {command.prefix} team: hcom send '@{command.prefix} message'")
2309
+ if tag:
2310
+ tips.append(f"Send to {tag} team: hcom send '@{tag} message'")
2342
2311
 
2343
2312
  if tips:
2344
2313
  print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
2345
2314
 
2346
- # Show cli_hints if configured (non-interactive mode)
2347
- if not is_interactive():
2348
- show_cli_hints(to_stderr=False)
2349
-
2350
2315
  return 0
2351
2316
 
2352
2317
  except ValueError as e:
@@ -2356,20 +2321,45 @@ def cmd_open(command: OpenCommand):
2356
2321
  print(str(e), file=sys.stderr)
2357
2322
  return 1
2358
2323
 
2359
- def cmd_watch(command: WatchCommand):
2360
- """View conversation dashboard"""
2324
+ def cmd_watch(argv: list[str]) -> int:
2325
+ """View conversation dashboard: hcom watch [--logs|--status|--wait [SEC]]"""
2326
+ # Extract launch flag for external terminals (used by claude code bootstrap)
2327
+ cleaned_args: list[str] = []
2328
+ for arg in argv:
2329
+ if arg == '--launch':
2330
+ watch_cmd = f"{build_hcom_command()} watch"
2331
+ result = launch_terminal(watch_cmd, build_claude_env(), cwd=os.getcwd())
2332
+ return 0 if result else 1
2333
+ else:
2334
+ cleaned_args.append(arg)
2335
+ argv = cleaned_args
2336
+
2337
+ # Parse arguments
2338
+ show_logs = '--logs' in argv
2339
+ show_status = '--status' in argv
2340
+ wait_timeout = None
2341
+
2342
+ # Check for --wait flag
2343
+ if '--wait' in argv:
2344
+ idx = argv.index('--wait')
2345
+ if idx + 1 < len(argv):
2346
+ try:
2347
+ wait_timeout = int(argv[idx + 1])
2348
+ if wait_timeout < 0:
2349
+ raise CLIError('--wait expects a non-negative number of seconds.')
2350
+ except ValueError:
2351
+ wait_timeout = 60 # Default for non-numeric values
2352
+ else:
2353
+ wait_timeout = 60 # Default timeout
2354
+ show_logs = True # --wait implies logs mode
2355
+
2361
2356
  log_file = hcom_path(LOG_FILE)
2362
2357
  instances_dir = hcom_path(INSTANCES_DIR)
2363
2358
 
2364
2359
  if not log_file.exists() and not instances_dir.exists():
2365
- print(format_error("No conversation log found", "Run 'hcom open' first"), file=sys.stderr)
2360
+ print(format_error("No conversation log found", "Run 'hcom' first"), file=sys.stderr)
2366
2361
  return 1
2367
2362
 
2368
- # Determine mode
2369
- show_logs = command.mode in ('logs', 'wait')
2370
- show_status = command.mode == 'status'
2371
- wait_timeout = command.wait_seconds
2372
-
2373
2363
  # Non-interactive mode (no TTY or flags specified)
2374
2364
  if not is_interactive() or show_logs or show_status:
2375
2365
  if show_logs:
@@ -2381,14 +2371,16 @@ def cmd_watch(command: WatchCommand):
2381
2371
  last_pos = 0
2382
2372
  messages = []
2383
2373
 
2384
- # If --wait, show only recent messages to prevent context bloat
2374
+ # If --wait, show recent messages (max of: last 3 messages OR all messages in last 5 seconds)
2385
2375
  if wait_timeout is not None:
2386
2376
  cutoff = datetime.now() - timedelta(seconds=5)
2387
- recent_messages = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
2388
-
2377
+ recent_by_time = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
2378
+ last_three = messages[-3:] if len(messages) >= 3 else messages
2379
+ # Show whichever is larger: recent by time or last 3
2380
+ recent_messages = recent_by_time if len(recent_by_time) > len(last_three) else last_three
2389
2381
  # Status to stderr, data to stdout
2390
2382
  if recent_messages:
2391
- 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.
2383
+ print(f'---Showing recent messages---', file=sys.stderr)
2392
2384
  for msg in recent_messages:
2393
2385
  print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
2394
2386
  print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
@@ -2426,7 +2418,6 @@ def cmd_watch(command: WatchCommand):
2426
2418
  else:
2427
2419
  print("No messages yet", file=sys.stderr)
2428
2420
 
2429
- show_cli_hints()
2430
2421
 
2431
2422
  elif show_status:
2432
2423
  # Build JSON output
@@ -2467,17 +2458,14 @@ def cmd_watch(command: WatchCommand):
2467
2458
  }
2468
2459
 
2469
2460
  print(json.dumps(output, indent=2))
2470
- show_cli_hints()
2471
2461
  else:
2472
2462
  print("No TTY - Automation usage:", file=sys.stderr)
2473
- print(" hcom send 'message' Send message to chat", file=sys.stderr)
2474
2463
  print(" hcom watch --logs Show message history", file=sys.stderr)
2475
2464
  print(" hcom watch --status Show instance status", file=sys.stderr)
2476
2465
  print(" hcom watch --wait Wait for new messages", file=sys.stderr)
2466
+ print(" hcom watch --launch Launch interactive dashboard in new terminal", file=sys.stderr)
2477
2467
  print(" Full information: hcom --help")
2478
2468
 
2479
- show_cli_hints()
2480
-
2481
2469
  return 0
2482
2470
 
2483
2471
  # Interactive dashboard mode
@@ -2552,9 +2540,9 @@ def cmd_watch(command: WatchCommand):
2552
2540
  last_pos = log_file.stat().st_size
2553
2541
 
2554
2542
  if message and message.strip():
2555
- cmd_send_cli(message.strip())
2543
+ send_cli(message.strip(), quiet=True)
2556
2544
  print(f"{FG_GREEN}✓ Sent{RESET}")
2557
-
2545
+
2558
2546
  print()
2559
2547
 
2560
2548
  current_status = get_status_summary()
@@ -2568,7 +2556,7 @@ def cmd_watch(command: WatchCommand):
2568
2556
 
2569
2557
  return 0
2570
2558
 
2571
- def cmd_clear():
2559
+ def clear() -> int:
2572
2560
  """Clear and archive conversation"""
2573
2561
  log_file = hcom_path(LOG_FILE)
2574
2562
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -2638,7 +2626,26 @@ def cmd_clear():
2638
2626
  print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
2639
2627
  return 1
2640
2628
 
2641
- def cleanup_directory_hooks(directory):
2629
+ def remove_global_hooks() -> bool:
2630
+ """Remove HCOM hooks from ~/.claude/settings.json
2631
+ Returns True on success, False on failure."""
2632
+ settings_path = get_claude_settings_path()
2633
+
2634
+ if not settings_path.exists():
2635
+ return True # No settings = no hooks to remove
2636
+
2637
+ try:
2638
+ settings = load_settings_json(settings_path, default=None)
2639
+ if not settings:
2640
+ return False
2641
+
2642
+ _remove_hcom_hooks_from_settings(settings)
2643
+ atomic_write(settings_path, json.dumps(settings, indent=2))
2644
+ return True
2645
+ except Exception:
2646
+ return False
2647
+
2648
+ def cleanup_directory_hooks(directory: Path | str) -> tuple[int, str]:
2642
2649
  """Remove hcom hooks from a specific directory
2643
2650
  Returns tuple: (exit_code, message)
2644
2651
  exit_code: 0 for success, 1 for error
@@ -2651,11 +2658,7 @@ def cleanup_directory_hooks(directory):
2651
2658
 
2652
2659
  try:
2653
2660
  # Load existing settings
2654
- settings = read_file_with_retry(
2655
- settings_path,
2656
- lambda f: json.load(f),
2657
- default=None
2658
- )
2661
+ settings = load_settings_json(settings_path, default=None)
2659
2662
  if not settings:
2660
2663
  return 1, "Cannot read Claude settings"
2661
2664
 
@@ -2692,51 +2695,40 @@ def cleanup_directory_hooks(directory):
2692
2695
  return 1, format_error(f"Cannot modify settings.local.json: {e}")
2693
2696
 
2694
2697
 
2695
- def cmd_stop(command: StopCommand):
2696
- """Stop instances, remove hooks, or archive - consolidated stop operations"""
2697
-
2698
- # Handle special targets
2699
- if command.target == 'hooking':
2700
- # hcom stop hooking [--all]
2701
- if command.close_all_hooks:
2702
- return cmd_cleanup('--all')
2703
- else:
2704
- return cmd_cleanup()
2705
-
2706
- elif command.target == 'everything':
2707
- # hcom stop everything: stop all + archive + remove hooks
2708
- print("Stopping HCOM for all instances, archiving conversation, and removing hooks...")
2709
-
2710
- # Stop all instances
2711
- positions = load_all_positions()
2712
- if positions:
2713
- for instance_name in positions.keys():
2714
- disable_instance(instance_name)
2715
- print(f"Stopped HCOM for {len(positions)} instance(s)")
2716
-
2717
- # Archive conversation
2718
- clear_result = cmd_clear()
2698
+ def cmd_stop(argv: list[str]) -> int:
2699
+ """Stop instances: hcom stop [alias|all] [--force] [--_hcom_session ID]"""
2700
+ # Parse arguments
2701
+ target = None
2702
+ force = '--force' in argv
2703
+ session_id = None
2719
2704
 
2720
- # Remove hooks from all directories
2721
- cleanup_result = cmd_cleanup('--all')
2705
+ # Extract --_hcom_session if present
2706
+ if '--_hcom_session' in argv:
2707
+ idx = argv.index('--_hcom_session')
2708
+ if idx + 1 < len(argv):
2709
+ session_id = argv[idx + 1]
2710
+ argv = argv[:idx] + argv[idx + 2:]
2722
2711
 
2723
- return max(clear_result, cleanup_result)
2712
+ # Remove flags to get target
2713
+ args_without_flags = [a for a in argv if not a.startswith('--')]
2714
+ if args_without_flags:
2715
+ target = args_without_flags[0]
2724
2716
 
2725
- elif command.target == 'all':
2726
- # hcom stop all: stop all instances + archive
2717
+ # Handle 'all' target
2718
+ if target == 'all':
2727
2719
  positions = load_all_positions()
2728
2720
 
2729
2721
  if not positions:
2730
2722
  print("No instances found")
2731
- # Still archive if there's conversation history
2732
- return cmd_clear()
2723
+ return 0
2733
2724
 
2734
2725
  stopped_count = 0
2735
2726
  bg_logs = []
2727
+ stopped_names = []
2736
2728
  for instance_name, instance_data in positions.items():
2737
2729
  if instance_data.get('enabled', False):
2738
2730
  disable_instance(instance_name)
2739
- print(f"Stopped HCOM for {instance_name}")
2731
+ stopped_names.append(instance_name)
2740
2732
  stopped_count += 1
2741
2733
 
2742
2734
  # Track background logs
@@ -2746,123 +2738,138 @@ def cmd_stop(command: StopCommand):
2746
2738
  bg_logs.append((instance_name, log_file))
2747
2739
 
2748
2740
  if stopped_count == 0:
2749
- print("All instances already stopped")
2741
+ print("No instances to stop")
2750
2742
  else:
2751
- print(f"Stopped {stopped_count} instance(s)")
2743
+ print(f"Stopped {stopped_count} instance(s): {', '.join(stopped_names)}")
2752
2744
 
2753
2745
  # Show background logs if any
2754
2746
  if bg_logs:
2755
- print("\nBackground logs:")
2747
+ print()
2748
+ print("Background instance logs:")
2756
2749
  for name, log_file in bg_logs:
2757
2750
  print(f" {name}: {log_file}")
2758
- print("\nMonitor: tail -f <log_file>")
2759
- print("Force stop: hcom stop --force all")
2760
2751
 
2761
- # Archive conversation
2762
- return cmd_clear()
2752
+ return 0
2763
2753
 
2754
+ # Stop specific instance or self
2755
+ # Get instance name from injected session or target
2756
+ if session_id and not target:
2757
+ instance_name, _ = resolve_instance_name(session_id, get_config().tag)
2764
2758
  else:
2765
- # hcom stop [alias] or hcom stop (self)
2759
+ instance_name = target
2766
2760
 
2767
- # Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
2768
- if not check_and_update_hooks():
2769
- return 1
2761
+ position = load_instance_position(instance_name) if instance_name else None
2770
2762
 
2771
- # Stop specific instance or self
2772
- # Get instance name from injected session or target
2773
- if command._hcom_session and not command.target:
2774
- instance_name, _ = resolve_instance_name(command._hcom_session, os.environ.get('HCOM_PREFIX'))
2763
+ if not instance_name:
2764
+ if os.environ.get('CLAUDECODE') == '1':
2765
+ print("Error: Cannot determine instance", file=sys.stderr)
2766
+ print("Usage: Prompt Claude to run 'hcom stop' (or directly use: hcom stop <alias> or hcom stop all)", file=sys.stderr)
2775
2767
  else:
2776
- instance_name = command.target
2777
-
2778
- position = load_instance_position(instance_name) if instance_name else None
2768
+ print("Error: Alias required", file=sys.stderr)
2769
+ print("Usage: hcom stop <alias>", file=sys.stderr)
2770
+ print(" Or: hcom stop all", file=sys.stderr)
2771
+ print(" Or: prompt claude to run 'hcom stop' on itself", file=sys.stderr)
2772
+ positions = load_all_positions()
2773
+ visible = [alias for alias, data in positions.items() if should_show_in_watch(data)]
2774
+ if visible:
2775
+ print(f"Active aliases: {', '.join(sorted(visible))}", file=sys.stderr)
2776
+ return 1
2779
2777
 
2780
- if not instance_name:
2781
- print("Error: Could not determine instance. Run inside Claude Code or specify alias. Or run hcom stop <alias> or hcom stop all")
2782
- return 1
2778
+ if not position:
2779
+ print(f"No instance found for {instance_name}")
2780
+ return 1
2783
2781
 
2784
- if not position:
2785
- print(f"No instance found for {instance_name}")
2786
- return 1
2782
+ # Skip already stopped instances (unless forcing)
2783
+ if not position.get('enabled', False) and not force:
2784
+ print(f"HCOM already stopped for {instance_name}")
2785
+ return 0
2787
2786
 
2788
- # Skip already stopped instances (unless forcing)
2789
- if not position.get('enabled', False) and not command.force:
2790
- print(f"HCOM already stopped for {instance_name}")
2791
- return 0
2787
+ # Disable instance (optionally with force)
2788
+ disable_instance(instance_name, force=force)
2792
2789
 
2793
- # Disable instance (optionally with force)
2794
- disable_instance(instance_name, force=command.force)
2790
+ if force:
2791
+ print(f"⚠️ Force stopped HCOM for {instance_name}.")
2792
+ print(f" Bash tool use is now DENIED.")
2793
+ print(f" To restart: hcom start {instance_name}")
2794
+ else:
2795
+ print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
2795
2796
 
2796
- if command.force:
2797
- print(f"⚠️ Force stopped HCOM for {instance_name}.")
2798
- print(f" Bash tool use is now DENIED. Instance is locked down.")
2799
- print(f" To restart: hcom start {instance_name}")
2800
- else:
2801
- print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
2797
+ # Show background log location if applicable
2798
+ if position.get('background'):
2799
+ log_file = position.get('background_log_file', '')
2800
+ if log_file:
2801
+ print(f"\nBackground log: {log_file}")
2802
+ print(f"Monitor: tail -f {log_file}")
2803
+ if not force:
2804
+ print(f"Force stop: hcom stop --force {instance_name}")
2802
2805
 
2803
- # Show background log location if applicable
2804
- if position.get('background'):
2805
- log_file = position.get('background_log_file', '')
2806
- if log_file:
2807
- print(f"\nBackground log: {log_file}")
2808
- print(f"Monitor: tail -f {log_file}")
2809
- if not command.force:
2810
- print(f"Force stop: hcom stop --force {instance_name}")
2806
+ return 0
2811
2807
 
2812
- return 0
2808
+ def cmd_start(argv: list[str]) -> int:
2809
+ """Enable HCOM participation: hcom start [alias] [--_hcom_session ID]"""
2810
+ # Parse arguments
2811
+ target = None
2812
+ session_id = None
2813
2813
 
2814
- def cmd_start(command: StartCommand):
2815
- """Enable HCOM participation for instances"""
2814
+ # Extract --_hcom_session if present
2815
+ if '--_hcom_session' in argv:
2816
+ idx = argv.index('--_hcom_session')
2817
+ if idx + 1 < len(argv):
2818
+ session_id = argv[idx + 1]
2819
+ argv = argv[:idx] + argv[idx + 2:]
2816
2820
 
2817
- # Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
2818
- if not check_and_update_hooks():
2819
- return 1
2821
+ # Remove flags to get target
2822
+ args_without_flags = [a for a in argv if not a.startswith('--')]
2823
+ if args_without_flags:
2824
+ target = args_without_flags[0]
2820
2825
 
2821
2826
  # Get instance name from injected session or target
2822
- if command._hcom_session and not command.target:
2823
- instance_name, existing_data = resolve_instance_name(command._hcom_session, os.environ.get('HCOM_PREFIX'))
2827
+ if session_id and not target:
2828
+ instance_name, existing_data = resolve_instance_name(session_id, get_config().tag)
2824
2829
 
2825
2830
  # Create instance if it doesn't exist (opt-in for vanilla instances)
2826
2831
  if not existing_data:
2827
- initialize_instance_in_position_file(instance_name, command._hcom_session)
2832
+ initialize_instance_in_position_file(instance_name, session_id)
2828
2833
  # Enable instance (clears all stop flags)
2829
2834
  enable_instance(instance_name)
2830
- print(f"Started HCOM for this instance. Your alias is: {instance_name}")
2835
+ print(f"\nStarted HCOM for {instance_name}")
2831
2836
  else:
2832
2837
  # Skip already started instances
2833
2838
  if existing_data.get('enabled', False):
2834
2839
  print(f"HCOM already started for {instance_name}")
2835
2840
  return 0
2836
2841
 
2842
+ # Check if background instance has exited permanently
2843
+ if existing_data.get('session_ended') and existing_data.get('background'):
2844
+ session = existing_data.get('session_id', '')
2845
+ print(f"Cannot start {instance_name}: background instance has exited permanently")
2846
+ print(f"Background instances terminate when stopped and cannot be restarted")
2847
+ if session:
2848
+ print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
2849
+ return 1
2850
+
2837
2851
  # Re-enabling existing instance
2838
2852
  enable_instance(instance_name)
2839
- print(f"Started HCOM for {instance_name}. Rejoined chat.")
2853
+ print(f"Started HCOM for {instance_name}")
2840
2854
 
2841
2855
  return 0
2842
2856
 
2843
- # Handle hooking target
2844
- if command.target == 'hooking':
2845
- # hcom start hooking: install hooks in current directory
2846
- if setup_hooks():
2847
- print("HCOM hooks installed in current directory")
2848
- print("Hooks active on next Claude Code launch in this directory")
2849
- return 0
2850
- else:
2851
- return 1
2852
-
2853
2857
  # CLI path: start specific instance
2854
2858
  positions = load_all_positions()
2855
2859
 
2856
2860
  # Handle missing target from external CLI
2857
- if not command.target:
2858
- print("Error: No instance specified & Not run by Claude Code\n")
2859
- print("Run by Claude Code: 'hcom start' starts HCOM for the current instance")
2860
- print("From anywhere: 'hcom start <alias>'\n")
2861
- print("To launch new instances: 'hcom open'")
2861
+ if not target:
2862
+ if os.environ.get('CLAUDECODE') == '1':
2863
+ print("Error: Cannot determine instance", file=sys.stderr)
2864
+ print("Usage: Prompt Claude to run 'hcom start' (or: hcom start <alias>)", file=sys.stderr)
2865
+ else:
2866
+ print("Error: Alias required", file=sys.stderr)
2867
+ print("Usage: hcom start <alias> (or: prompt claude to run 'hcom start')", file=sys.stderr)
2868
+ print("To launch new instances: hcom <count>", file=sys.stderr)
2862
2869
  return 1
2863
2870
 
2864
2871
  # Start specific instance
2865
- instance_name = command.target
2872
+ instance_name = target
2866
2873
  position = positions.get(instance_name)
2867
2874
 
2868
2875
  if not position:
@@ -2874,17 +2881,81 @@ def cmd_start(command: StartCommand):
2874
2881
  print(f"HCOM already started for {instance_name}")
2875
2882
  return 0
2876
2883
 
2884
+ # Check if background instance has exited permanently
2885
+ if position.get('session_ended') and position.get('background'):
2886
+ session = position.get('session_id', '')
2887
+ print(f"Cannot start {instance_name}: background instance has exited permanently")
2888
+ print(f"Background instances terminate when stopped and cannot be restarted")
2889
+ if session:
2890
+ print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
2891
+ return 1
2892
+
2877
2893
  # Enable instance (clears all stop flags)
2878
2894
  enable_instance(instance_name)
2879
2895
 
2880
2896
  print(f"Started HCOM for {instance_name}. Rejoined chat.")
2881
2897
  return 0
2882
2898
 
2883
- def cmd_cleanup(*args):
2899
+ def cmd_reset(argv: list[str]) -> int:
2900
+ """Reset HCOM components: logs, hooks, config
2901
+ Usage:
2902
+ hcom reset # Everything (stop all + logs + hooks + config)
2903
+ hcom reset logs # Archive conversation only
2904
+ hcom reset hooks # Remove hooks only
2905
+ hcom reset config # Clear config (backup to config.env.TIMESTAMP)
2906
+ hcom reset logs hooks # Combine targets
2907
+ """
2908
+ # No args = everything
2909
+ do_everything = not argv
2910
+ targets = argv if argv else ['logs', 'hooks', 'config']
2911
+
2912
+ # Validate targets
2913
+ valid = {'logs', 'hooks', 'config'}
2914
+ invalid = [t for t in targets if t not in valid]
2915
+ if invalid:
2916
+ print(f"Invalid target(s): {', '.join(invalid)}", file=sys.stderr)
2917
+ print("Valid targets: logs, hooks, config", file=sys.stderr)
2918
+ return 1
2919
+
2920
+ exit_codes = []
2921
+
2922
+ # Stop all instances if doing everything
2923
+ if do_everything:
2924
+ exit_codes.append(cmd_stop(['all']))
2925
+
2926
+ # Execute based on targets
2927
+ if 'logs' in targets:
2928
+ exit_codes.append(clear())
2929
+
2930
+ if 'hooks' in targets:
2931
+ exit_codes.append(cleanup('--all'))
2932
+ if remove_global_hooks():
2933
+ print("Removed hooks")
2934
+ else:
2935
+ print("Warning: Could not remove hooks. Check your claude settings.json file it might be invalid", file=sys.stderr)
2936
+ exit_codes.append(1)
2937
+
2938
+ if 'config' in targets:
2939
+ config_path = hcom_path(CONFIG_FILE)
2940
+ if config_path.exists():
2941
+ # Backup with timestamp
2942
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
2943
+ backup_path = hcom_path(f'config.env.{timestamp}')
2944
+ shutil.copy2(config_path, backup_path)
2945
+ config_path.unlink()
2946
+ print(f"Config backed up to config.env.{timestamp} and cleared")
2947
+ exit_codes.append(0)
2948
+ else:
2949
+ print("No config file to clear")
2950
+ exit_codes.append(0)
2951
+
2952
+ return max(exit_codes) if exit_codes else 0
2953
+
2954
+ def cleanup(*args: str) -> int:
2884
2955
  """Remove hcom hooks from current directory or all directories"""
2885
2956
  if args and args[0] == '--all':
2886
2957
  directories = set()
2887
-
2958
+
2888
2959
  # Get all directories from current instances
2889
2960
  try:
2890
2961
  positions = load_all_positions()
@@ -2894,6 +2965,24 @@ def cmd_cleanup(*args):
2894
2965
  directories.add(instance_data['directory'])
2895
2966
  except Exception as e:
2896
2967
  print(f"Warning: Could not read current instances: {e}")
2968
+
2969
+ # Also check archived instances for directories (until 0.5.0)
2970
+ try:
2971
+ archive_dir = hcom_path(ARCHIVE_DIR)
2972
+ if archive_dir.exists():
2973
+ for session_dir in archive_dir.iterdir():
2974
+ if session_dir.is_dir() and session_dir.name.startswith('session-'):
2975
+ instances_dir = session_dir / 'instances'
2976
+ if instances_dir.exists():
2977
+ for instance_file in instances_dir.glob('*.json'):
2978
+ try:
2979
+ data = json.loads(instance_file.read_text())
2980
+ if 'directory' in data:
2981
+ directories.add(data['directory'])
2982
+ except Exception:
2983
+ pass
2984
+ except Exception as e:
2985
+ print(f"Warning: Could not read archived instances: {e}")
2897
2986
 
2898
2987
  if not directories:
2899
2988
  print("No directories found in current HCOM tracking")
@@ -2937,42 +3026,61 @@ def cmd_cleanup(*args):
2937
3026
  print(message)
2938
3027
  return exit_code
2939
3028
 
2940
- def check_and_update_hooks() -> bool:
2941
- """Verify hooks are correct when running inside Claude Code, reinstall if needed.
2942
- Returns True if hooks are good (continue), False if hooks were updated (restart needed)."""
2943
- if os.environ.get('CLAUDECODE') != '1':
2944
- return True # Not in Claude Code, continue normally
3029
+ def ensure_hooks_current() -> bool:
3030
+ """Ensure hooks match current execution context - called on EVERY command.
3031
+ Auto-updates hooks if execution context changes (e.g., pip uvx).
3032
+ Always returns True (warns but never blocks - Claude Code is fault-tolerant)."""
2945
3033
 
2946
- # Check both cwd and home for hooks
2947
- cwd_settings = Path.cwd() / '.claude' / 'settings.local.json'
2948
- home_settings = Path.home() / '.claude' / 'settings.local.json'
3034
+ # Verify hooks exist and match current execution context
3035
+ global_settings = get_claude_settings_path()
2949
3036
 
2950
- # If hooks are correctly installed, continue
2951
- if verify_hooks_installed(cwd_settings) or verify_hooks_installed(home_settings):
2952
- return True
3037
+ # Check if hooks are valid (exist + env var matches current context)
3038
+ hooks_exist = verify_hooks_installed(global_settings)
3039
+ env_var_matches = False
2953
3040
 
2954
- # Hooks missing or incorrect - reinstall them
2955
- try:
2956
- setup_hooks()
2957
- print("Hooks updated. Restart Claude Code to use HCOM.", file=sys.stderr)
2958
- except Exception as e:
2959
- print(f"Failed to update hooks: {e}", file=sys.stderr)
2960
- print("Try running: hcom open from normal terminal", file=sys.stderr)
2961
- return False
3041
+ if hooks_exist:
3042
+ try:
3043
+ settings = load_settings_json(global_settings, default={})
3044
+ if settings is None:
3045
+ settings = {}
3046
+ current_hcom = _build_hcom_env_value()
3047
+ installed_hcom = settings.get('env', {}).get('HCOM')
3048
+ env_var_matches = (installed_hcom == current_hcom)
3049
+ except Exception:
3050
+ # Failed to read settings - try to fix by updating
3051
+ env_var_matches = False
2962
3052
 
2963
- def cmd_send(command: SendCommand, force_cli=False):
2964
- """Send message to hcom, force cli for config sender instead of instance generated name"""
3053
+ # Install/update hooks if missing or env var wrong
3054
+ if not hooks_exist or not env_var_matches:
3055
+ try:
3056
+ setup_hooks()
3057
+ if os.environ.get('CLAUDECODE') == '1':
3058
+ print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
3059
+ print("=" * 60, file=sys.stderr)
3060
+ except Exception as e:
3061
+ # Failed to verify/update hooks, but they might still work
3062
+ # Claude Code is fault-tolerant with malformed JSON
3063
+ print(f"⚠️ Could not verify/update hooks: {e}", file=sys.stderr)
3064
+ print("If HCOM doesn't work, check ~/.claude/settings.json", file=sys.stderr)
2965
3065
 
2966
- # Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
2967
- if not check_and_update_hooks():
2968
- return 1
3066
+ return True
3067
+
3068
+ def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> int:
3069
+ """Send message to hcom: hcom send "message" [--_hcom_session ID]"""
3070
+ # Parse message and session_id
3071
+ message = None
3072
+ session_id = None
2969
3073
 
2970
- # Handle resume command - pass caller session_id
2971
- if command.resume_alias:
2972
- caller_session = command._hcom_session # May be None for CLI
2973
- return cmd_resume_merge(command.resume_alias, caller_session)
3074
+ # Extract --_hcom_session if present (injected by PreToolUse hook)
3075
+ if '--_hcom_session' in argv:
3076
+ idx = argv.index('--_hcom_session')
3077
+ if idx + 1 < len(argv):
3078
+ session_id = argv[idx + 1]
3079
+ argv = argv[:idx] + argv[idx + 2:] # Remove flag and value
2974
3080
 
2975
- message = command.message
3081
+ # First non-flag argument is the message
3082
+ if argv:
3083
+ message = argv[0]
2976
3084
 
2977
3085
  # Check message is provided
2978
3086
  if not message:
@@ -2984,7 +3092,7 @@ def cmd_send(command: SendCommand, force_cli=False):
2984
3092
  instances_dir = hcom_path(INSTANCES_DIR)
2985
3093
 
2986
3094
  if not log_file.exists() and not instances_dir.exists():
2987
- print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
3095
+ print(format_error("No conversation found", "Run 'hcom <count>' first"), file=sys.stderr)
2988
3096
  return 1
2989
3097
 
2990
3098
  # Validate message
@@ -2999,7 +3107,7 @@ def cmd_send(command: SendCommand, force_cli=False):
2999
3107
  try:
3000
3108
  positions = load_all_positions()
3001
3109
  all_instances = list(positions.keys())
3002
- sender_name = get_config_value('sender_name', 'bigboss')
3110
+ sender_name = SENDER
3003
3111
  all_names = all_instances + [sender_name]
3004
3112
  unmatched = [m for m in mentions
3005
3113
  if not any(name.lower().startswith(m.lower()) for name in all_names)]
@@ -3009,19 +3117,17 @@ def cmd_send(command: SendCommand, force_cli=False):
3009
3117
  pass # Don't fail on warning
3010
3118
 
3011
3119
  # Determine sender from injected session_id or CLI
3012
- if command._hcom_session and not force_cli:
3013
- # Instance context - get name from session_id
3120
+ if session_id and not force_cli:
3121
+ # Instance context - resolve name from session_id (searches existing instances first)
3014
3122
  try:
3015
- sender_name = get_display_name(command._hcom_session)
3123
+ sender_name, instance_data = resolve_instance_name(session_id, get_config().tag)
3016
3124
  except (ValueError, Exception) as e:
3017
3125
  print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
3018
3126
  return 1
3019
3127
 
3020
- instance_data = load_instance_position(sender_name)
3021
-
3022
3128
  # Initialize instance if doesn't exist (first use)
3023
3129
  if not instance_data:
3024
- initialize_instance_in_position_file(sender_name, command._hcom_session)
3130
+ initialize_instance_in_position_file(sender_name, session_id)
3025
3131
  instance_data = load_instance_position(sender_name)
3026
3132
 
3027
3133
  # Check force_closed
@@ -3042,88 +3148,40 @@ def cmd_send(command: SendCommand, force_cli=False):
3042
3148
  # Show unread messages
3043
3149
  messages = get_unread_messages(sender_name, update_position=True)
3044
3150
  if messages:
3045
- max_msgs = get_config_value('max_messages_per_delivery', 50)
3151
+ max_msgs = MAX_MESSAGES_PER_DELIVERY
3046
3152
  formatted = format_hook_messages(messages[:max_msgs], sender_name)
3047
3153
  print(f"Message sent\n\n{formatted}", file=sys.stderr)
3048
3154
  else:
3049
3155
  print("Message sent", file=sys.stderr)
3050
3156
 
3051
- # Show cli_hints if configured (non-interactive mode)
3052
- if not is_interactive():
3053
- show_cli_hints()
3054
-
3055
3157
  return 0
3056
3158
  else:
3057
3159
  # CLI context - no session_id or force_cli=True
3058
- sender_name = get_config_value('sender_name', 'bigboss')
3160
+
3161
+ # Warn if inside Claude Code but no session_id (hooks not working)
3162
+ if os.environ.get('CLAUDECODE') == '1' and not session_id and not force_cli:
3163
+ print(f"⚠️ Cannot determine alias - message sent as '{SENDER}'", file=sys.stderr)
3164
+ print(" Prompt Claude to send a hcom message instead of using bash mode (! prefix).", file=sys.stderr)
3165
+
3166
+
3167
+ sender_name = SENDER
3059
3168
 
3060
3169
  if not send_message(sender_name, message):
3061
3170
  print(format_error("Failed to send message"), file=sys.stderr)
3062
3171
  return 1
3063
3172
 
3064
- print(f"✓ Sent from {sender_name}", file=sys.stderr)
3065
-
3066
- # Show cli_hints if configured (non-interactive mode)
3067
- if not is_interactive():
3068
- show_cli_hints()
3173
+ if not quiet:
3174
+ print(f"✓ Sent from {sender_name}", file=sys.stderr)
3069
3175
 
3070
3176
  return 0
3071
3177
 
3072
- def cmd_send_cli(message):
3178
+ def send_cli(message: str, quiet: bool = False) -> int:
3073
3179
  """Force CLI sender (skip outbox, use config sender name)"""
3074
- command = SendCommand(message=message, resume_alias=None)
3075
- return cmd_send(command, force_cli=True)
3076
-
3077
- def cmd_resume_merge(alias: str, caller_session: str | None = None) -> int:
3078
- """Resume/merge current instance into an existing instance by alias.
3079
- INTERNAL COMMAND: Only called via 'hcom send --resume alias' during implicit resume workflow.
3080
- Not meant for direct CLI usage.
3081
- Args:
3082
- alias: Target instance alias to merge into
3083
- caller_session: Session ID of caller (injected by PreToolUse hook) or None for CLI
3084
- """
3085
- # If caller_session provided (from hcom send --resume), use it
3086
- if caller_session:
3087
- instance_name = get_display_name(caller_session)
3088
- else:
3089
- # CLI path - no session context
3090
- print(format_error("Not in HCOM instance context"), file=sys.stderr)
3091
- return 1
3092
-
3093
- if not instance_name:
3094
- print(format_error("Could not determine instance name"), file=sys.stderr)
3095
- return 1
3096
-
3097
- # Sanitize alias: must be valid instance name format
3098
- # Base: 5 lowercase alphanumeric (e.g., hova3)
3099
- # Prefixed: {prefix}-{5 chars} (e.g., api-hova3, cool-team-kolec)
3100
- # This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
3101
- if not re.match(r'^([a-zA-Z0-9_-]+-)?[a-z0-9]{5}$', alias):
3102
- print(format_error("Invalid alias format. Must be 5-char instance name or prefix-name format"), file=sys.stderr)
3103
- return 1
3104
-
3105
- # Attempt to merge current instance into target alias
3106
- status = merge_instance_immediately(instance_name, alias)
3107
-
3108
- # Handle results
3109
- if not status:
3110
- # Empty status means names matched (from_name == to_name)
3111
- status = f"[SUCCESS] ✓ Already using HCOM alias {alias}. Rejoined chat."
3112
- elif status.startswith('[SUCCESS]'):
3113
- # Merge successful - update message
3114
- status = f"[SUCCESS] ✓ Resumed HCOM as {alias}. Rejoined chat."
3115
-
3116
- # If merge successful, enable instance (clears session_ended and stop flags)
3117
- if status.startswith('[SUCCESS]'):
3118
- enable_instance(alias)
3119
-
3120
- # Print status and return
3121
- print(status, file=sys.stderr)
3122
- return 0 if status.startswith('[SUCCESS]') else 1
3180
+ return cmd_send([message], force_cli=True, quiet=quiet)
3123
3181
 
3124
3182
  # ==================== Hook Helpers ====================
3125
3183
 
3126
- def format_hook_messages(messages, instance_name):
3184
+ def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
3127
3185
  """Format messages for hook feedback"""
3128
3186
  if len(messages) == 1:
3129
3187
  msg = messages[0]
@@ -3132,100 +3190,42 @@ def format_hook_messages(messages, instance_name):
3132
3190
  parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
3133
3191
  reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
3134
3192
 
3135
- # Only append instance_hints to messages (first_use_text is handled separately)
3136
- instance_hints = get_config_value('instance_hints', '')
3137
- if instance_hints:
3138
- reason = f"{reason} | [{instance_hints}]"
3193
+ # Only append hints to messages
3194
+ hints = get_config().hints
3195
+ if hints:
3196
+ reason = f"{reason} | [{hints}]"
3139
3197
 
3140
3198
  return reason
3141
3199
 
3142
3200
  # ==================== Hook Handlers ====================
3143
3201
 
3144
- def detect_session_scenario(
3145
- hook_type: str | None,
3146
- session_id: str,
3147
- source: str,
3148
- existing_data: dict | None
3149
- ) -> SessionScenario | None:
3202
+ def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -> tuple[str, dict[str, Any], bool]:
3150
3203
  """
3151
- Detect session startup scenario explicitly.
3152
-
3153
- Returns:
3154
- SessionScenario for definitive scenarios
3155
- None for deferred decision (SessionStart with wrong session_id)
3156
- """
3157
- if existing_data is not None:
3158
- # Found existing instance with matching session_id
3159
- return SessionScenario.MATCHED_RESUME
3160
-
3161
- if hook_type == 'sessionstart' and source == 'resume':
3162
- # SessionStart on resume without match = wrong session_id
3163
- # Don't know if truly unmatched yet - UserPromptSubmit will decide
3164
- return None # Deferred decision
3165
-
3166
- if hook_type == 'userpromptsubmit' and source == 'resume':
3167
- # UserPromptSubmit on resume without match = definitively unmatched
3168
- return SessionScenario.UNMATCHED_RESUME
3169
-
3170
- # Normal startup
3171
- return SessionScenario.FRESH_START
3172
-
3173
-
3174
- def should_create_instance_file(scenario: SessionScenario | None, hook_type: str | None) -> bool:
3175
- """
3176
- Decide whether to create instance file NOW.
3177
-
3178
- Simplified: Only UserPromptSubmit creates instances.
3179
- SessionStart just shows minimal message and tracks status.
3180
- """
3181
- # Only UserPromptSubmit creates instances
3182
- if hook_type != 'userpromptsubmit':
3183
- return False
3184
-
3185
- # Create for new scenarios only (not matched resume which already exists)
3186
- return scenario in (SessionScenario.FRESH_START, SessionScenario.UNMATCHED_RESUME)
3187
-
3188
-
3189
- def init_hook_context(hook_data, hook_type=None):
3190
- """
3191
- Initialize instance context with explicit scenario detection.
3192
-
3193
- Flow:
3204
+ Initialize instance context. Flow:
3194
3205
  1. Resolve instance name (search by session_id, generate if not found)
3195
- 2. Detect scenario (fresh/matched/unmatched/deferred)
3196
- 3. Decide whether to create file NOW
3197
- 4. Return context with all decisions made
3206
+ 2. Create instance file if fresh start in UserPromptSubmit
3207
+ 3. Build updates dict
3208
+ 4. Return (instance_name, updates, is_matched_resume)
3198
3209
  """
3199
3210
  session_id = hook_data.get('session_id', '')
3200
3211
  transcript_path = hook_data.get('transcript_path', '')
3201
- source = hook_data.get('source', 'startup')
3202
- prefix = os.environ.get('HCOM_PREFIX')
3212
+ tag = get_config().tag
3203
3213
 
3204
- # Step 1: Resolve instance name
3205
- instance_name, existing_data = resolve_instance_name(session_id, prefix)
3214
+ # Resolve instance name - existing_data is None for fresh starts
3215
+ instance_name, existing_data = resolve_instance_name(session_id, tag)
3206
3216
 
3207
3217
  # Save migrated data if we have it
3208
3218
  if existing_data:
3209
3219
  save_instance_position(instance_name, existing_data)
3210
3220
 
3211
- # Step 2: Detect scenario
3212
- scenario = detect_session_scenario(hook_type, session_id, source, existing_data)
3213
-
3214
- # Check if instance is brand new (before creation - for bypass logic)
3215
- instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3216
- is_new_instance = not instance_file.exists()
3217
-
3218
-
3219
- # Step 3: Decide creation
3220
- should_create = should_create_instance_file(scenario, hook_type)
3221
-
3222
-
3223
- if should_create:
3221
+ # Create instance file if fresh start in UserPromptSubmit
3222
+ if existing_data is None and hook_type == 'userpromptsubmit':
3224
3223
  initialize_instance_in_position_file(instance_name, session_id)
3225
3224
 
3226
- # Step 4: Build updates dict
3225
+ # Build updates dict
3227
3226
  updates: dict[str, Any] = {
3228
3227
  'directory': str(Path.cwd()),
3228
+ 'tag': tag,
3229
3229
  }
3230
3230
 
3231
3231
  if session_id:
@@ -3239,11 +3239,10 @@ def init_hook_context(hook_data, hook_type=None):
3239
3239
  updates['background'] = True
3240
3240
  updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
3241
3241
 
3242
- # Return compatible with existing callers
3243
- is_resume_match = (scenario == SessionScenario.MATCHED_RESUME)
3242
+ # Simple boolean: matched resume if existing_data found
3243
+ is_matched_resume = (existing_data is not None)
3244
3244
 
3245
-
3246
- return instance_name, updates, is_resume_match, is_new_instance
3245
+ return instance_name, updates, is_matched_resume
3247
3246
 
3248
3247
  def pretooluse_decision(decision: str, reason: str) -> None:
3249
3248
  """Exit PreToolUse hook with permission decision"""
@@ -3255,9 +3254,9 @@ def pretooluse_decision(decision: str, reason: str) -> None:
3255
3254
  }
3256
3255
  }
3257
3256
  print(json.dumps(output, ensure_ascii=False))
3258
- sys.exit(EXIT_SUCCESS)
3257
+ sys.exit(0)
3259
3258
 
3260
- def handle_pretooluse(hook_data, instance_name, updates):
3259
+ def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3261
3260
  """Handle PreToolUse hook - check force_closed, inject session_id"""
3262
3261
  instance_data = load_instance_position(instance_name)
3263
3262
  tool_name = hook_data.get('tool_name', '')
@@ -3271,18 +3270,16 @@ def handle_pretooluse(hook_data, instance_name, updates):
3271
3270
  if instance_data.get('enabled', False):
3272
3271
  set_status(instance_name, 'tool_pending', tool_name)
3273
3272
 
3274
- # Inject session_id into hcom send/stop/start commands via updatedInput
3273
+ # Inject session_id into hcom commands via updatedInput
3275
3274
  if tool_name == 'Bash' and session_id:
3276
3275
  command = hook_data.get('tool_input', {}).get('command', '')
3277
3276
 
3278
- # Match: (hcom|uvx hcom|python X hcom.py|X hcom.py) (send|stop|start)
3279
- # Handles: hcom, uvx hcom, python hcom.py, python /path/to/hcom.py, hcom.py, /path/to/hcom.py
3280
- hcom_pattern = r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+(send|stop|start)\b'
3281
-
3282
- # Check if command contains any hcom invocations
3283
- if re.search(hcom_pattern, command):
3284
- # Inject session_id after EACH hcom command (handles chained commands)
3285
- modified_command = re.sub(hcom_pattern, rf'\g<0> --_hcom_session {session_id}', command)
3277
+ # Match hcom commands for session_id injection and auto-approval
3278
+ matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
3279
+ if matches:
3280
+ # Inject all if chained (&&, ||, ;, |), otherwise first only (avoids quoted text in messages)
3281
+ inject_all = len(matches) > 1 and any(op in command[matches[0].end():matches[1].start()] for op in ['&&', '||', ';', '|'])
3282
+ modified_command = HCOM_COMMAND_PATTERN.sub(rf'\g<0> --_hcom_session {session_id}', command, count=0 if inject_all else 1)
3286
3283
 
3287
3284
  output = {
3288
3285
  "hookSpecificOutput": {
@@ -3294,17 +3291,16 @@ def handle_pretooluse(hook_data, instance_name, updates):
3294
3291
  }
3295
3292
  }
3296
3293
  print(json.dumps(output, ensure_ascii=False))
3297
- sys.exit(EXIT_SUCCESS)
3294
+ sys.exit(0)
3298
3295
 
3299
3296
 
3300
3297
 
3301
- def handle_stop(hook_data, instance_name, updates):
3298
+ def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3302
3299
  """Handle Stop hook - poll for messages and deliver"""
3303
3300
 
3304
3301
  try:
3305
- entry_time = time.time()
3306
- updates['last_stop'] = entry_time
3307
- timeout = get_config_value('wait_timeout', 1800)
3302
+ updates['last_stop'] = time.time()
3303
+ timeout = get_config().timeout
3308
3304
  updates['wait_timeout'] = timeout
3309
3305
  set_status(instance_name, 'waiting')
3310
3306
 
@@ -3316,39 +3312,48 @@ def handle_stop(hook_data, instance_name, updates):
3316
3312
  start_time = time.time()
3317
3313
 
3318
3314
  try:
3319
- loop_count = 0
3315
+ first_poll = True
3320
3316
  last_heartbeat = start_time
3321
3317
  # Actual polling loop - this IS the holding pattern
3322
3318
  while time.time() - start_time < timeout:
3323
- if loop_count == 0:
3324
- time.sleep(0.1) # Initial wait before first poll
3325
- loop_count += 1
3319
+ if first_poll:
3320
+ first_poll = False
3326
3321
 
3327
- # Load instance data once per poll
3322
+ # Reload instance data each poll iteration
3328
3323
  instance_data = load_instance_position(instance_name)
3329
3324
 
3325
+ # Check flag file FIRST (highest priority coordination signal)
3326
+ flag_file = get_user_input_flag_file(instance_name)
3327
+ if flag_file.exists():
3328
+ try:
3329
+ flag_file.unlink()
3330
+ except (FileNotFoundError, PermissionError):
3331
+ # Already deleted or locked, continue anyway
3332
+ pass
3333
+ sys.exit(0)
3334
+
3330
3335
  # Check if session ended (SessionEnd hook fired) - exit without changing status
3331
3336
  if instance_data.get('session_ended'):
3332
- sys.exit(EXIT_SUCCESS) # Don't overwrite session_ended status
3337
+ sys.exit(0) # Don't overwrite session_ended status
3333
3338
 
3334
- # Check if user input is pending - exit cleanly if recent input
3339
+ # Check if user input is pending (timestamp fallback) - exit cleanly if recent input
3335
3340
  last_user_input = instance_data.get('last_user_input', 0)
3336
3341
  if time.time() - last_user_input < 0.2:
3337
- sys.exit(EXIT_SUCCESS) # Don't overwrite status - let current status remain
3342
+ sys.exit(0) # Don't overwrite status - let current status remain
3338
3343
 
3339
- # Check if closed - exit cleanly
3344
+ # Check if stopped/disabled - exit cleanly
3340
3345
  if not instance_data.get('enabled', False):
3341
- sys.exit(EXIT_SUCCESS) # Preserve 'stopped' status set by cmd_stop
3346
+ sys.exit(0) # Preserve 'stopped' status set by cmd_stop
3342
3347
 
3343
3348
  # Check for new messages and deliver
3344
3349
  if messages := get_unread_messages(instance_name, update_position=True):
3345
- messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
3350
+ messages_to_show = messages[:MAX_MESSAGES_PER_DELIVERY]
3346
3351
  reason = format_hook_messages(messages_to_show, instance_name)
3347
3352
  set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
3348
3353
 
3349
3354
  output = {"decision": "block", "reason": reason}
3350
3355
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
3351
- sys.exit(EXIT_BLOCK)
3356
+ sys.exit(2)
3352
3357
 
3353
3358
  # Update heartbeat every 0.5 seconds for staleness detection
3354
3359
  now = time.time()
@@ -3367,51 +3372,70 @@ def handle_stop(hook_data, instance_name, updates):
3367
3372
 
3368
3373
  # Timeout reached
3369
3374
  set_status(instance_name, 'timeout')
3375
+ sys.exit(0)
3370
3376
 
3371
3377
  except Exception as e:
3372
3378
  # Log error and exit gracefully
3373
3379
  log_hook_error('handle_stop', e)
3374
- sys.exit(EXIT_SUCCESS) # Preserve previous status on exception
3380
+ sys.exit(0) # Preserve previous status on exception
3375
3381
 
3376
- def handle_notify(hook_data, instance_name, updates):
3382
+ def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3377
3383
  """Handle Notification hook - track permission requests"""
3378
3384
  updates['notification_message'] = hook_data.get('message', '')
3379
3385
  update_instance_position(instance_name, updates)
3380
3386
  set_status(instance_name, 'blocked', hook_data.get('message', ''))
3381
3387
 
3382
- def wait_for_stop_exit(instance_name, max_wait=0.2):
3383
- """Wait for Stop hook to exit. Returns wait time in ms."""
3388
+ def get_user_input_flag_file(instance_name: str) -> Path:
3389
+ """Get path to user input coordination flag file"""
3390
+ return hcom_path(FLAGS_DIR, f'{instance_name}.user_input')
3391
+
3392
+ def wait_for_stop_exit(instance_name: str, max_wait: float = 0.2) -> int:
3393
+ """
3394
+ Wait for Stop hook to exit using flag file coordination.
3395
+ Returns wait time in ms.
3396
+ Strategy:
3397
+ 1. Create flag file
3398
+ 2. Wait for Stop hook to delete it (proof it exited)
3399
+ 3. Fallback to timeout if Stop hook doesn't delete flag
3400
+ """
3384
3401
  start = time.time()
3402
+ flag_file = get_user_input_flag_file(instance_name)
3385
3403
 
3386
- while time.time() - start < max_wait:
3404
+ # Wait for flag file to be deleted by Stop hook
3405
+ while flag_file.exists() and time.time() - start < max_wait:
3387
3406
  time.sleep(0.01)
3388
3407
 
3389
- data = load_instance_position(instance_name)
3390
- last_stop_age = time.time() - data.get('last_stop', 0)
3391
-
3392
- if last_stop_age > 0.2:
3393
- return int((time.time() - start) * 1000)
3394
-
3395
3408
  return int((time.time() - start) * 1000)
3396
3409
 
3397
- def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
3410
+ def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], is_matched_resume: bool, instance_data: dict[str, Any] | None) -> None:
3398
3411
  """Handle UserPromptSubmit hook - track when user sends messages"""
3399
- # Load instance data for coordination check and alias_announced
3400
- instance_data = load_instance_position(instance_name)
3401
- is_enabled = instance_data.get('enabled', False)
3402
- last_stop = instance_data.get('last_stop', 0)
3403
- alias_announced = instance_data.get('alias_announced', False)
3412
+ is_enabled = instance_data.get('enabled', False) if instance_data else False
3413
+ last_stop = instance_data.get('last_stop', 0) if instance_data else 0
3414
+ alias_announced = instance_data.get('alias_announced', False) if instance_data else False
3415
+
3416
+ # Session_ended prevents user receiving messages(?) so reset it.
3417
+ if is_matched_resume and instance_data and instance_data.get('session_ended'):
3418
+ update_instance_position(instance_name, {'session_ended': False})
3419
+ instance_data['session_ended'] = False # Resume path reactivates Stop hook polling
3404
3420
 
3405
3421
  # Coordinate with Stop hook only if enabled AND Stop hook is active
3406
3422
  stop_is_active = (time.time() - last_stop) < 1.0
3407
3423
 
3408
3424
  if is_enabled and stop_is_active:
3425
+ # Create flag file for coordination
3426
+ flag_file = get_user_input_flag_file(instance_name)
3427
+ try:
3428
+ flag_file.touch()
3429
+ except (OSError, PermissionError):
3430
+ # Failed to create flag, fall back to timestamp-only coordination
3431
+ pass
3432
+
3433
+ # Set timestamp (backup mechanism)
3409
3434
  updates['last_user_input'] = time.time()
3410
3435
  update_instance_position(instance_name, updates)
3411
- wait_ms = wait_for_stop_exit(instance_name)
3412
3436
 
3413
- send_cmd = build_send_command('your message', instance_name)
3414
- resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
3437
+ # Wait for Stop hook to delete flag file
3438
+ wait_for_stop_exit(instance_name)
3415
3439
 
3416
3440
  # Build message based on what happened
3417
3441
  msg = None
@@ -3419,18 +3443,8 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
3419
3443
  # Determine if this is an HCOM-launched instance
3420
3444
  is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
3421
3445
 
3422
- # PRIORITY 1: Handle unmatched resume FIRST (is_new_instance=True means SessionStart was skipped)
3423
- # This must show critical recovery message regardless of HCOM_LAUNCHED status
3424
- if is_new_instance:
3425
- msg = build_hcom_bootstrap_text(instance_name)
3426
- msg += (
3427
- f"\n\n[CRITICAL: HCOM RESUME DETECTED - You MUST recover your identity to maintain conversation context. "
3428
- f"Run this command: {resume_cmd} - This is REQUIRED for message history and position tracking]"
3429
- )
3430
- update_instance_position(instance_name, {'alias_announced': True})
3431
-
3432
- # PRIORITY 2: Normal startup - show bootstrap if not already announced
3433
- elif not alias_announced:
3446
+ # Show bootstrap if not already announced
3447
+ if not alias_announced:
3434
3448
  if is_hcom_launched:
3435
3449
  # HCOM-launched instance - show bootstrap immediately
3436
3450
  msg = build_hcom_bootstrap_text(instance_name)
@@ -3445,54 +3459,76 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
3445
3459
  msg += build_hcom_bootstrap_text(instance_name)
3446
3460
  update_instance_position(instance_name, {'alias_announced': True})
3447
3461
 
3448
- # PRIORITY 3: Add resume status note if we showed bootstrap for a matched resume
3449
- if msg and is_resume_match and not is_new_instance:
3462
+ # Add resume status note if we showed bootstrap for a matched resume
3463
+ if msg and is_matched_resume:
3450
3464
  if is_enabled:
3451
- msg += "\n[Session resumed. HCOM started for this instance - will receive chat messages. Your alias and conversation history preserved.]"
3452
- else:
3453
- 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.]"
3454
-
3465
+ msg += "\n[HCOM Session resumed. Your alias and conversation history preserved.]"
3455
3466
  if msg:
3456
3467
  output = {
3457
- # "systemMessage": "HCOM enabled",
3458
3468
  "hookSpecificOutput": {
3459
3469
  "hookEventName": "UserPromptSubmit",
3460
3470
  "additionalContext": msg
3461
3471
  }
3462
3472
  }
3463
3473
  print(json.dumps(output), file=sys.stdout)
3464
- # sys.exit(1)
3465
-
3466
- def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
3467
- """Handle SessionStart hook - minimal message, full details on first prompt"""
3468
- source = hook_data.get('source', 'startup')
3469
-
3470
- # Update instance if it exists (matched resume only, since we don't create in SessionStart anymore)
3471
- instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3472
- if instance_file.exists():
3473
- updates['alias_announced'] = False
3474
- update_instance_position(instance_name, updates)
3475
- set_status(instance_name, 'session_start')
3476
-
3477
- # Minimal message - no alias yet (UserPromptSubmit will show full details)
3478
- help_text = "[HCOM active. Submit a prompt to initialize.]"
3479
3474
 
3480
- # Add first_use_text only for hcom-launched instances on startup
3481
- if os.environ.get('HCOM_LAUNCHED') == '1' and source == 'startup':
3482
- first_use_text = get_config_value('first_use_text', '')
3483
- if first_use_text:
3484
- help_text += f" [{first_use_text}]"
3475
+ def handle_sessionstart(hook_data: dict[str, Any]) -> None:
3476
+ """Handle SessionStart hook - initial msg & reads environment variables"""
3477
+ # Only show message for HCOM-launched instances
3478
+ if os.environ.get('HCOM_LAUNCHED') == '1':
3479
+ parts = f"[HCOM is started, you can send messages with the command: {build_hcom_command()} send]"
3480
+ else:
3481
+ parts = f"[You can start HCOM with the command: {build_hcom_command()} start]"
3485
3482
 
3486
3483
  output = {
3487
3484
  "hookSpecificOutput": {
3488
3485
  "hookEventName": "SessionStart",
3489
- "additionalContext": help_text
3486
+ "additionalContext": parts
3490
3487
  }
3491
3488
  }
3492
3489
 
3493
3490
  print(json.dumps(output))
3494
3491
 
3495
- def handle_sessionend(hook_data, instance_name, updates):
3492
+ def handle_posttooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3493
+ """Handle PostToolUse hook - show launch context or bootstrap"""
3494
+ command = hook_data.get('tool_input', {}).get('command', '')
3495
+ instance_data = load_instance_position(instance_name)
3496
+
3497
+ # Check for help or launch commands (combined pattern)
3498
+ if re.search(r'\bhcom\s+(?:(?:help|--help|-h)\b|\d+)', command):
3499
+ if not instance_data.get('launch_context_announced', False):
3500
+ msg = build_launch_context(instance_name)
3501
+ update_instance_position(instance_name, {'launch_context_announced': True})
3502
+
3503
+ output = {
3504
+ "hookSpecificOutput": {
3505
+ "hookEventName": "PostToolUse",
3506
+ "additionalContext": msg
3507
+ }
3508
+ }
3509
+ print(json.dumps(output, ensure_ascii=False))
3510
+ return
3511
+
3512
+ # Check HCOM_COMMAND_PATTERN for bootstrap (other hcom commands)
3513
+ matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
3514
+
3515
+ if not matches:
3516
+ return
3517
+
3518
+ # Show bootstrap if not announced yet
3519
+ if not instance_data.get('alias_announced', False):
3520
+ msg = build_hcom_bootstrap_text(instance_name)
3521
+ update_instance_position(instance_name, {'alias_announced': True})
3522
+
3523
+ output = {
3524
+ "hookSpecificOutput": {
3525
+ "hookEventName": "PostToolUse",
3526
+ "additionalContext": msg
3527
+ }
3528
+ }
3529
+ print(json.dumps(output, ensure_ascii=False))
3530
+
3531
+ def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3496
3532
  """Handle SessionEnd hook - mark session as ended and set final status"""
3497
3533
  reason = hook_data.get('reason', 'unknown')
3498
3534
 
@@ -3507,138 +3543,139 @@ def handle_sessionend(hook_data, instance_name, updates):
3507
3543
  except Exception as e:
3508
3544
  log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
3509
3545
 
3546
+ def should_skip_vanilla_instance(hook_type: str, hook_data: dict) -> bool:
3547
+ """
3548
+ Returns True if hook should exit early.
3549
+ Vanilla instances (not HCOM-launched) exit early unless:
3550
+ - Enabled
3551
+ - PreToolUse (handles opt-in)
3552
+ - UserPromptSubmit with hcom command in prompt (shows preemptive bootstrap)
3553
+ """
3554
+ # PreToolUse always runs (handles toggle commands)
3555
+ # HCOM-launched instances always run
3556
+ if hook_type == 'pre' or os.environ.get('HCOM_LAUNCHED') == '1':
3557
+ return False
3558
+
3559
+ session_id = hook_data.get('session_id', '')
3560
+ if not session_id: # No session_id = can't identify instance, skip hook
3561
+ return True
3562
+
3563
+ instance_name = get_display_name(session_id, get_config().tag)
3564
+ instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3565
+
3566
+ if not instance_file.exists():
3567
+ # Allow UserPromptSubmit if prompt contains hcom command
3568
+ if hook_type == 'userpromptsubmit':
3569
+ user_prompt = hook_data.get('prompt', '')
3570
+ return not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE)
3571
+ return True
3572
+
3573
+ return False
3574
+
3510
3575
  def handle_hook(hook_type: str) -> None:
3511
3576
  """Unified hook handler for all HCOM hooks"""
3512
3577
  hook_data = json.load(sys.stdin)
3513
3578
 
3514
3579
  if not ensure_hcom_directories():
3515
3580
  log_hook_error('handle_hook', Exception('Failed to create directories'))
3516
- sys.exit(EXIT_SUCCESS)
3517
-
3518
- session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
3519
- source_debug = hook_data.get('source', 'NO_SOURCE')
3520
-
3521
- # Vanilla instance check (not hcom-launched)
3522
- # Exit early if no instance file exists, except:
3523
- # - PreToolUse (handles first send opt-in)
3524
- # - UserPromptSubmit with hcom command (shows preemptive bootstrap)
3525
- if hook_type != 'pre' and os.environ.get('HCOM_LAUNCHED') != '1':
3526
- session_id = hook_data.get('session_id', '')
3527
- if not session_id:
3528
- sys.exit(EXIT_SUCCESS)
3529
-
3530
- instance_name = get_display_name(session_id, os.environ.get('HCOM_PREFIX'))
3531
- instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3532
-
3533
- if not instance_file.exists():
3534
- # Allow UserPromptSubmit through if prompt contains hcom command
3535
- if hook_type == 'userpromptsubmit':
3536
- user_prompt = hook_data.get('prompt', '')
3537
- if not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE):
3538
- sys.exit(EXIT_SUCCESS)
3539
- # Continue - let handle_userpromptsubmit show bootstrap
3540
- else:
3541
- sys.exit(EXIT_SUCCESS)
3581
+ sys.exit(0)
3542
3582
 
3543
- # Initialize instance context (creates file if needed, reuses existing if session_id matches)
3544
- instance_name, updates, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
3583
+ # SessionStart is standalone - no instance files
3584
+ if hook_type == 'sessionstart':
3585
+ handle_sessionstart(hook_data)
3586
+ sys.exit(0)
3545
3587
 
3546
- # Special bypass for unmatched resume - must show critical warning even if disabled
3547
- # is_new_instance=True in UserPromptSubmit means SessionStart didn't create it (was skipped)
3548
- if hook_type == 'userpromptsubmit' and is_new_instance:
3549
- handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
3550
- sys.exit(EXIT_SUCCESS)
3588
+ # Vanilla instance check - exit early if should skip
3589
+ if should_skip_vanilla_instance(hook_type, hook_data):
3590
+ sys.exit(0)
3551
3591
 
3552
- # Check enabled status (PreToolUse handles toggle, so exempt)
3592
+ # Initialize instance context (creates file if needed, reuses existing if session_id matches)
3593
+ instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
3594
+
3595
+ # Load instance data once (for enabled check and to pass to handlers)
3596
+ instance_data = None
3553
3597
  if hook_type != 'pre':
3554
3598
  instance_data = load_instance_position(instance_name)
3555
- if not instance_data.get('enabled', False):
3556
- sys.exit(EXIT_SUCCESS)
3599
+
3600
+ # Skip enabled check for UserPromptSubmit when bootstrap needs to be shown
3601
+ # (alias_announced=false means bootstrap hasn't been shown yet)
3602
+ skip_enabled_check = (hook_type == 'userpromptsubmit' and
3603
+ not instance_data.get('alias_announced', False))
3604
+
3605
+ if not skip_enabled_check and not instance_data.get('enabled', False):
3606
+ sys.exit(0)
3557
3607
 
3558
3608
  match hook_type:
3559
3609
  case 'pre':
3560
- handle_pretooluse(hook_data, instance_name, updates)
3610
+ handle_pretooluse(hook_data, instance_name)
3611
+ case 'post':
3612
+ handle_posttooluse(hook_data, instance_name)
3561
3613
  case 'poll':
3562
- handle_stop(hook_data, instance_name, updates)
3614
+ handle_stop(hook_data, instance_name, updates, instance_data)
3563
3615
  case 'notify':
3564
- handle_notify(hook_data, instance_name, updates)
3616
+ handle_notify(hook_data, instance_name, updates, instance_data)
3565
3617
  case 'userpromptsubmit':
3566
- handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
3567
- case 'sessionstart':
3568
- handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
3618
+ handle_userpromptsubmit(hook_data, instance_name, updates, is_matched_resume, instance_data)
3569
3619
  case 'sessionend':
3570
- handle_sessionend(hook_data, instance_name, updates)
3620
+ handle_sessionend(hook_data, instance_name, updates, instance_data)
3571
3621
 
3572
- sys.exit(EXIT_SUCCESS)
3622
+ sys.exit(0)
3573
3623
 
3574
3624
 
3575
3625
  # ==================== Main Entry Point ====================
3576
3626
 
3577
- def main(argv=None):
3627
+ def main(argv: list[str] | None = None) -> int | None:
3578
3628
  """Main command dispatcher"""
3579
3629
  if argv is None:
3580
3630
  argv = sys.argv[1:]
3581
3631
  else:
3582
3632
  argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
3583
3633
 
3584
- # Check for help
3585
- if needs_help(argv):
3586
- return cmd_help()
3587
-
3588
- # Handle hook commands (special case - no parsing needed)
3589
- if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
3634
+ # Hook handlers only (called BY hooks, not users)
3635
+ if argv and argv[0] in ('poll', 'notify', 'pre', 'post', 'sessionstart', 'userpromptsubmit', 'sessionend'):
3590
3636
  handle_hook(argv[0])
3591
3637
  return 0
3592
3638
 
3593
- # Handle send_cli (hidden command)
3594
- if argv and argv[0] == 'send_cli':
3595
- if len(argv) < 2:
3596
- print(format_error("Message required"), file=sys.stderr)
3597
- return 1
3598
- return cmd_send_cli(argv[1])
3599
-
3600
- # Split on -- separator for forwarding args
3601
- hcom_args, forwarded = split_forwarded_args(argv)
3602
-
3603
- # Ensure directories exist for commands that need them
3604
- if hcom_args and hcom_args[0] not in ('help', '--help', '-h'):
3605
- if not ensure_hcom_directories():
3606
- print(format_error("Failed to create HCOM directories"), file=sys.stderr)
3607
- return 1
3639
+ # Ensure directories exist first (required for version check cache)
3640
+ if not ensure_hcom_directories():
3641
+ print(format_error("Failed to create HCOM directories"), file=sys.stderr)
3642
+ return 1
3608
3643
 
3609
- # Build parser and parse arguments
3610
- parser = build_parser()
3644
+ # Check for updates and show message if available (once daily check, persists until upgrade)
3645
+ if msg := get_update_notice():
3646
+ print(msg, file=sys.stderr)
3611
3647
 
3612
- try:
3613
- namespace = parser.parse_args(hcom_args)
3614
- except SystemExit as exc:
3615
- if exc.code != 0:
3616
- print("Run 'hcom -h' for help", file=sys.stderr)
3617
- return exc.code if isinstance(exc.code, int) else 1
3648
+ # Ensure hooks current (warns but never blocks)
3649
+ ensure_hooks_current()
3618
3650
 
3619
- # Dispatch to command parsers and get typed command objects
3651
+ # Route to commands
3620
3652
  try:
3621
- command_obj = dispatch(namespace, forwarded)
3622
-
3623
- # command_obj could be exit code (from help) or command dataclass
3624
- if isinstance(command_obj, int):
3625
- return command_obj
3626
-
3627
- # Execute the command with typed object
3628
- if isinstance(command_obj, OpenCommand):
3629
- return cmd_open(command_obj)
3630
- elif isinstance(command_obj, WatchCommand):
3631
- return cmd_watch(command_obj)
3632
- elif isinstance(command_obj, StopCommand):
3633
- return cmd_stop(command_obj)
3634
- elif isinstance(command_obj, StartCommand):
3635
- return cmd_start(command_obj)
3636
- elif isinstance(command_obj, SendCommand):
3637
- return cmd_send(command_obj)
3653
+ if not argv or argv[0] in ('help', '--help', '-h'):
3654
+ return cmd_help()
3655
+ elif argv[0] == 'send_cli':
3656
+ if len(argv) < 2:
3657
+ print(format_error("Message required"), file=sys.stderr)
3658
+ return 1
3659
+ return send_cli(argv[1])
3660
+ elif argv[0] == 'watch':
3661
+ return cmd_watch(argv[1:])
3662
+ elif argv[0] == 'send':
3663
+ return cmd_send(argv[1:])
3664
+ elif argv[0] == 'stop':
3665
+ return cmd_stop(argv[1:])
3666
+ elif argv[0] == 'start':
3667
+ return cmd_start(argv[1:])
3668
+ elif argv[0] == 'reset':
3669
+ return cmd_reset(argv[1:])
3670
+ elif argv[0].isdigit() or argv[0] == 'claude':
3671
+ # Launch instances: hcom <1-100> [args] or hcom claude [args]
3672
+ return cmd_launch(argv)
3638
3673
  else:
3639
- print(format_error(f"Unknown command type: {type(command_obj)}"), file=sys.stderr)
3674
+ print(format_error(
3675
+ f"Unknown command: {argv[0]}",
3676
+ "Run 'hcom --help' for usage"
3677
+ ), file=sys.stderr)
3640
3678
  return 1
3641
-
3642
3679
  except CLIError as exc:
3643
3680
  print(str(exc), file=sys.stderr)
3644
3681
  return 1