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

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

Potentially problematic release.


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

hcom/__main__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- hcom 0.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, NamedTuple
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
@@ -91,7 +59,6 @@ CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
91
59
  # Timing constants
92
60
  FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
93
61
  STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
94
- MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
95
62
 
96
63
  MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
97
64
  AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
@@ -148,95 +115,66 @@ if IS_WINDOWS or is_wsl():
148
115
  # ==================== Error Handling Strategy ====================
149
116
  # Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
150
117
  # CLI: Can raise exceptions for user feedback. Check return values.
151
- # Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
118
+ # Critical I/O: atomic_write, save_instance_position
152
119
  # Pattern: Try/except/return False in hooks, raise in CLI operations.
153
120
 
154
- # ==================== CLI Command Objects ====================
121
+ # ==================== CLI Errors ====================
155
122
 
156
123
  class CLIError(Exception):
157
124
  """Raised when arguments cannot be mapped to command semantics."""
158
125
 
159
- @dataclass
160
- class OpenCommand:
161
- count: int
162
- agents: list[str]
163
- prefix: str | None
164
- background: bool
165
- claude_args: list[str]
126
+ # ==================== Help Text ====================
166
127
 
167
- @dataclass
168
- class WatchCommand:
169
- mode: str # 'interactive', 'logs', 'status', 'wait'
170
- wait_seconds: int | None
128
+ HELP_TEXT = """hcom - Claude Hook Comms
171
129
 
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
130
+ Usage: [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
131
+ hcom watch [--logs|--status|--wait [SEC]]
132
+ hcom send "message"
133
+ hcom stop [alias|all] [--force]
134
+ hcom start [alias]
135
+ hcom reset [logs|hooks|config]
178
136
 
179
- @dataclass
180
- class StartCommand:
181
- target: str | None
182
- _hcom_session: str | None = None # Injected by PreToolUse hook
137
+ Launch Examples:
138
+ hcom 3 Open 3 terminals with claude connected to hcom
139
+ hcom 3 claude -p + Background/headless
140
+ HCOM_TAG=api hcom 3 claude -p + @-mention group tag
183
141
 
184
- @dataclass
185
- class SendCommand:
186
- message: str | None
187
- resume_alias: str | None
188
- _hcom_session: str | None = None # Injected by PreToolUse hook
142
+ Commands:
143
+ watch Interactive messaging/status dashboard
144
+ --logs Print all messages
145
+ --status Print instance status JSON
146
+ --wait [SEC] Wait and notify for new message
189
147
 
190
- # ==================== Help Text ====================
148
+ send "msg" Send message to all instances
149
+ send "@alias msg" Send to specific instance/group
191
150
 
192
- HELP_TEXT = """hcom - Claude Hook Comms
151
+ stop Stop current instance (from inside Claude)
152
+ stop <alias> Stop specific instance
153
+ stop all Stop all instances
154
+ --force Emergency stop (denies Bash tool)
193
155
 
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"
156
+ start Start current instance (from inside Claude)
157
+ start <alias> Start specific instance
158
+
159
+ reset Stop all + archive logs + remove hooks + clear config
160
+ reset logs Clear + archive conversation log
161
+ reset hooks Safely remove hcom hooks from claude settings.json
162
+ reset config Clear + backup config.env
163
+
164
+ Environment Variables:
165
+ HCOM_TAG=name Group tag (creates name-* instances)
166
+ HCOM_AGENT=type Agent type (comma-separated for multiple)
167
+ HCOM_TERMINAL=mode Terminal: new|here|print|"custom {script}"
168
+ HCOM_PROMPT=text Initial prompt
169
+ HCOM_TIMEOUT=secs Timeout in seconds (default: 1800)
170
+
171
+ Config: ~/.hcom/config.env
172
+ Docs: https://github.com/aannoo/claude-hook-comms"""
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,41 +191,22 @@ 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
207
  LOGS_DIR = "logs"
289
208
  SCRIPTS_DIR = "scripts"
290
- CONFIG_FILE = "config.json"
209
+ CONFIG_FILE = "config.env"
291
210
  ARCHIVE_DIR = "archive"
292
211
 
293
212
  # Hook type constants
@@ -296,6 +215,31 @@ LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES + ['PostToolUse'] # For backward compatib
296
215
  HOOK_COMMANDS = ['sessionstart', 'userpromptsubmit', 'pre', 'poll', 'notify', 'sessionend']
297
216
  LEGACY_HOOK_COMMANDS = HOOK_COMMANDS + ['post']
298
217
 
218
+ # Hook removal patterns - used by _remove_hcom_hooks_from_settings()
219
+ # Dynamically build from LEGACY_HOOK_COMMANDS to match current and legacy hook formats
220
+ _HOOK_ARGS_PATTERN = '|'.join(LEGACY_HOOK_COMMANDS)
221
+ HCOM_HOOK_PATTERNS = [
222
+ re.compile(r'\$\{?HCOM'), # Current: Environment variable ${HCOM:-...}
223
+ re.compile(r'\bHCOM_ACTIVE.*hcom\.py'), # LEGACY: Unix HCOM_ACTIVE conditional
224
+ re.compile(r'IF\s+"%HCOM_ACTIVE%"'), # LEGACY: Windows HCOM_ACTIVE conditional
225
+ re.compile(rf'\bhcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: Direct hcom command
226
+ re.compile(rf'\buvx\s+hcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: uvx hcom command
227
+ re.compile(rf'hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: hcom.py with optional quote
228
+ re.compile(rf'["\'][^"\']*hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b(?=\s|$)'), # LEGACY: Quoted path
229
+ re.compile(r'sh\s+-c.*hcom'), # LEGACY: Shell wrapper
230
+ ]
231
+
232
+ # PreToolUse hook pattern - matches hcom commands for session_id injection and auto-approval
233
+ # - hcom send (any args)
234
+ # - hcom stop (no args) | hcom start (no args)
235
+ # - hcom help | hcom --help | hcom -h
236
+ # - hcom watch --status | hcom watch --launch
237
+ # Negative lookahead (?!\s+[-\w]) ensures stop/start not followed by arguments or flags
238
+ HCOM_COMMAND_PATTERN = re.compile(
239
+ r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+'
240
+ r'(?:send\b|(?:stop|start)(?!\s+[-\w])|(?:help|--help|-h)\b|watch\s+(?:--status|--launch)\b)'
241
+ )
242
+
299
243
  # ==================== File System Utilities ====================
300
244
 
301
245
  def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
@@ -374,10 +318,6 @@ def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, m
374
318
 
375
319
  return default
376
320
 
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
321
  def get_instance_file(instance_name: str) -> Path:
382
322
  """Get path to instance's position file with path traversal protection"""
383
323
  # Sanitize instance name to prevent directory traversal
@@ -409,6 +349,18 @@ def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
409
349
  except (OSError, PermissionError, ValueError):
410
350
  return False
411
351
 
352
+ def get_claude_settings_path() -> Path:
353
+ """Get path to global Claude settings file"""
354
+ return Path.home() / '.claude' / 'settings.json'
355
+
356
+ def load_settings_json(settings_path: Path, default: Any = None) -> dict[str, Any] | None:
357
+ """Load and parse settings JSON file with retry logic"""
358
+ return read_file_with_retry(
359
+ settings_path,
360
+ lambda f: json.load(f),
361
+ default=default
362
+ )
363
+
412
364
  def load_all_positions() -> dict[str, dict[str, Any]]:
413
365
  """Load positions from all instance files"""
414
366
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -436,93 +388,294 @@ def clear_all_positions() -> None:
436
388
 
437
389
  # ==================== Configuration System ====================
438
390
 
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
391
+ @dataclass
392
+ class HcomConfig:
393
+ """HCOM configuration with validation. Load priority: env → file → defaults"""
394
+ timeout: int = 1800
395
+ terminal: str = 'new'
396
+ prompt: str = 'say hi in hcom chat'
397
+ hints: str = ''
398
+ tag: str = ''
399
+ agent: str = 'generic'
400
+
401
+ def __post_init__(self):
402
+ """Validate configuration on construction"""
403
+ errors = self.validate()
404
+ if errors:
405
+ raise ValueError(f"Invalid config:\n" + "\n".join(f" - {e}" for e in errors))
406
+
407
+ def validate(self) -> list[str]:
408
+ """Validate all fields, return list of errors"""
409
+ errors = []
410
+
411
+ # Validate timeout
412
+ # Validate timeout (bool is subclass of int in Python, must check explicitly)
413
+ if isinstance(self.timeout, bool):
414
+ errors.append(f"timeout must be an integer, not boolean (got {self.timeout})")
415
+ elif not isinstance(self.timeout, int):
416
+ errors.append(f"timeout must be an integer, got {type(self.timeout).__name__}")
417
+ elif not 1 <= self.timeout <= 86400:
418
+ errors.append(f"timeout must be 1-86400 seconds (24 hours), got {self.timeout}")
419
+
420
+ # Validate terminal
421
+ if not isinstance(self.terminal, str):
422
+ errors.append(f"terminal must be a string, got {type(self.terminal).__name__}")
423
+ else:
424
+ valid_modes = ('new', 'here', 'print')
425
+ if self.terminal not in valid_modes and '{script}' not in self.terminal:
426
+ errors.append(
427
+ f"terminal must be one of {valid_modes} or custom command with {{script}}, "
428
+ f"got '{self.terminal}'"
429
+ )
430
+
431
+ # Validate tag (only alphanumeric and hyphens - security: prevent log delimiter injection)
432
+ if not isinstance(self.tag, str):
433
+ errors.append(f"tag must be a string, got {type(self.tag).__name__}")
434
+ elif self.tag and not re.match(r'^[a-zA-Z0-9-]+$', self.tag):
435
+ errors.append("tag can only contain letters, numbers, and hyphens")
436
+
437
+ # Validate agent
438
+ if not isinstance(self.agent, str):
439
+ errors.append(f"agent must be a string, got {type(self.agent).__name__}")
440
+
441
+ return errors
442
+
443
+ @classmethod
444
+ def load(cls) -> 'HcomConfig':
445
+ """Load config with precedence: env var → file → defaults"""
446
+ # Ensure config file exists
447
+ config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
448
+ created_config = False
449
+ if not config_path.exists():
450
+ _write_default_config(config_path)
451
+ created_config = True
452
+
453
+ # Warn once if legacy config.json still exists when creating config.env
454
+ legacy_config = hcom_path('config.json')
455
+ if created_config and legacy_config.exists():
456
+ print(
457
+ format_error(
458
+ "Found legacy ~/.hcom/config.json; new config file is: ~/.hcom/config.env."
459
+ ),
460
+ file=sys.stderr,
461
+ )
462
+
463
+ # Parse config file once
464
+ file_config = _parse_env_file(config_path) if config_path.exists() else {}
465
+
466
+ def get_var(key: str) -> str | None:
467
+ """Get variable with precedence: env → file"""
468
+ if key in os.environ:
469
+ return os.environ[key]
470
+ if key in file_config:
471
+ return file_config[key]
472
+ return None
445
473
 
446
- def _load_config_from_file() -> dict:
447
- """Load configuration from ~/.hcom/config.json"""
448
- config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
474
+ data = {}
475
+
476
+ # Load timeout (requires int conversion)
477
+ timeout_str = get_var('HCOM_TIMEOUT')
478
+ if timeout_str is not None:
479
+ try:
480
+ data['timeout'] = int(timeout_str)
481
+ except (ValueError, TypeError):
482
+ pass # Use default
483
+
484
+ # Load string values
485
+ terminal = get_var('HCOM_TERMINAL')
486
+ if terminal is not None:
487
+ data['terminal'] = terminal
488
+ prompt = get_var('HCOM_PROMPT')
489
+ if prompt is not None:
490
+ data['prompt'] = prompt
491
+ hints = get_var('HCOM_HINTS')
492
+ if hints is not None:
493
+ data['hints'] = hints
494
+ tag = get_var('HCOM_TAG')
495
+ if tag is not None:
496
+ data['tag'] = tag
497
+ agent = get_var('HCOM_AGENT')
498
+ if agent is not None:
499
+ data['agent'] = agent
500
+
501
+ return cls(**data) # Validation happens in __post_init__
502
+
503
+ def _parse_env_file(config_path: Path) -> dict[str, str]:
504
+ """Parse ENV file (KEY=VALUE format) with security validation"""
505
+ config = {}
449
506
 
450
- # Start with default config as dict
451
- config_dict = asdict(DEFAULT_CONFIG)
507
+ # Dangerous shell metacharacters that enable command injection
508
+ DANGEROUS_CHARS = ['`', '$', ';', '|', '&', '\n', '\r']
452
509
 
453
510
  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
511
+ content = config_path.read_text(encoding='utf-8')
512
+ for line in content.splitlines():
513
+ line = line.strip()
514
+ if not line or line.startswith('#'):
515
+ continue
516
+ if '=' in line:
517
+ key, _, value = line.partition('=')
518
+ key = key.strip()
519
+ value = value.strip()
520
+
521
+ # Security: Validate HCOM_TERMINAL for command injection
522
+ if key == 'HCOM_TERMINAL':
523
+ if any(c in value for c in DANGEROUS_CHARS):
524
+ print(
525
+ f"Warning: Unsafe characters in HCOM_TERMINAL "
526
+ f"({', '.join(repr(c) for c in DANGEROUS_CHARS if c in value)}), "
527
+ f"ignoring custom terminal command",
528
+ file=sys.stderr
529
+ )
530
+ continue
531
+ # Additional check: custom commands must contain {script} placeholder
532
+ if value not in ('new', 'here', 'print') and '{script}' not in value:
533
+ print(
534
+ f"Warning: HCOM_TERMINAL custom command must include {{script}} placeholder, "
535
+ f"ignoring",
536
+ file=sys.stderr
537
+ )
538
+ continue
539
+
540
+ # Remove outer quotes only if they match
541
+ if len(value) >= 2:
542
+ if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
543
+ value = value[1:-1]
544
+ if key:
545
+ config[key] = value
546
+ except (FileNotFoundError, PermissionError, UnicodeDecodeError):
547
+ pass
548
+ return config
549
+
550
+ def _write_default_config(config_path: Path) -> None:
551
+ """Write default config file with documentation"""
552
+ header = """# HCOM Configuration
553
+ #
554
+ # All HCOM_* settings can be set here (persistent) or via environment variables (temporary).
555
+ # Environment variables override config file values.
556
+ #
557
+ # HCOM settings:
558
+ # HCOM_TIMEOUT - Instance Stop hook wait timeout in seconds (default: 1800)
559
+ # HCOM_TERMINAL - Terminal mode: "new", "here", "print", or custom command with {script}
560
+ # HCOM_PROMPT - Initial prompt for new instances (empty = no auto prompt)
561
+ # HCOM_HINTS - Text appended to all messages received by instances
562
+ # HCOM_TAG - Group tag for instances (creates tag-* instances)
563
+ # HCOM_AGENT - Claude code subagent from .claude/agents/, comma-separated for multiple
564
+ #
565
+ # NOTE: Inline comments are not supported. Use separate comment lines.
566
+ #
567
+ # Claude Code settings (passed to Claude instances):
568
+ # ANTHROPIC_MODEL=opus
569
+ # Any other Claude Code environment variable
570
+ #
571
+ # Custom terminal examples:
572
+ # HCOM_TERMINAL="wezterm start -- bash {script}"
573
+ # HCOM_TERMINAL="kitty -e bash {script}"
574
+ #
575
+ """
576
+ defaults = [
577
+ 'HCOM_TIMEOUT=1800',
578
+ 'HCOM_TERMINAL=new',
579
+ 'HCOM_PROMPT=say hi in hcom chat',
580
+ 'HCOM_HINTS=',
581
+ ]
582
+ try:
583
+ atomic_write(config_path, header + '\n'.join(defaults) + '\n')
584
+ except Exception:
585
+ pass
491
586
 
492
- config = get_cached_config()
493
- return config.get(key, default)
587
+ # Global config instance (cached)
588
+ _config: HcomConfig | None = None
589
+
590
+ def get_config() -> HcomConfig:
591
+ """Get cached config, loading if needed"""
592
+ global _config
593
+ if _config is None:
594
+ _config = HcomConfig.load()
595
+ return _config
596
+
597
+ def _build_quoted_invocation() -> str:
598
+ """Build properly quoted python + script path for current platform"""
599
+ python_path = sys.executable
600
+ script_path = str(Path(__file__).resolve())
494
601
 
495
- def get_hook_command():
602
+ if IS_WINDOWS:
603
+ if ' ' in python_path or ' ' in script_path:
604
+ return f'"{python_path}" "{script_path}"'
605
+ return f'{python_path} {script_path}'
606
+ else:
607
+ return f'{shlex.quote(python_path)} {shlex.quote(script_path)}'
608
+
609
+ def get_hook_command() -> tuple[str, dict[str, Any]]:
496
610
  """Get hook command - hooks always run, Python code gates participation
497
611
 
498
612
  Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
499
613
  Participation is controlled by enabled flag in instance JSON files.
500
614
  """
501
- python_path = sys.executable
502
- script_path = str(Path(__file__).resolve())
503
-
504
615
  if IS_WINDOWS:
505
616
  # 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}', {}
617
+ return _build_quoted_invocation(), {}
509
618
  else:
510
- # Unix: Use HCOM env var from settings.local.json
619
+ # Unix: Use HCOM env var from settings.json
511
620
  return '${HCOM}', {}
512
621
 
513
622
  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'):
623
+ """Detect how to invoke hcom based on execution context
624
+ Priority:
625
+ 1. short - If plugin enabled (plugin installs hcom binary to PATH)
626
+ 2. uvx - If running in uv-managed Python and uvx available
627
+ (works for both temporary uvx runs and permanent uv tool install)
628
+ 3. short - If hcom binary in PATH
629
+ 4. full - Fallback to full python invocation
630
+
631
+ Note: uvx hcom reuses uv tool install environments with zero overhead.
632
+ """
633
+ if is_plugin_active():
516
634
  return 'short'
517
635
  elif 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
518
636
  return 'uvx'
637
+ elif shutil.which('hcom'):
638
+ return 'short'
519
639
  else:
520
640
  return 'full'
521
641
 
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 ''
642
+ def check_version_once_daily() -> None:
643
+ """Check PyPI for newer version, show update command based on install method"""
644
+ cache_file = hcom_path() / '.version_check'
645
+ try:
646
+ if cache_file.exists() and time.time() - cache_file.stat().st_mtime < 86400:
647
+ return
648
+
649
+ import urllib.request
650
+ with urllib.request.urlopen('https://pypi.org/pypi/hcom/json', timeout=2) as f:
651
+ data = json.load(f)
652
+ latest = data['info']['version']
653
+
654
+ # Simple version comparison (tuple of ints)
655
+ def parse_version(v: str) -> tuple:
656
+ return tuple(int(x) for x in v.split('.') if x.isdigit())
657
+
658
+ if parse_version(latest) > parse_version(__version__):
659
+ # Use existing detection to show correct update command
660
+ if _detect_hcom_command_type() == 'uvx':
661
+ update_cmd = "uv tool upgrade hcom"
662
+ else:
663
+ update_cmd = "pip install -U hcom"
664
+
665
+ print(f"→ hcom v{latest} available: {update_cmd}", file=sys.stderr)
666
+
667
+ cache_file.touch() # Update cache
668
+ except:
669
+ pass # Silent fail on network/parse errors
525
670
 
671
+ def _build_hcom_env_value() -> str:
672
+ """Build the value for settings['env']['HCOM'] based on current execution context
673
+ Uses build_hcom_command() without caching for fresh detection on every call.
674
+ """
675
+ return build_hcom_command(None)
676
+
677
+ def build_hcom_command(instance_name: str | None = None) -> str:
678
+ """Build base hcom command - caches PATH check in instance file on first use"""
526
679
  # Determine command type (cached or detect)
527
680
  cmd_type = None
528
681
  if instance_name:
@@ -540,32 +693,40 @@ def build_send_command(example_msg: str = '', instance_name: str | None = None)
540
693
 
541
694
  # Build command based on type
542
695
  if cmd_type == 'short':
543
- return f'hcom send{msg}'
696
+ return 'hcom'
544
697
  elif cmd_type == 'uvx':
545
- return f'uvx hcom send{msg}'
698
+ return 'uvx hcom'
546
699
  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}'
700
+ # Full path fallback
701
+ return _build_quoted_invocation()
550
702
 
551
- def build_claude_env():
552
- """Build environment variables for Claude instances!"""
553
- env = {}
703
+ def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
704
+ """Build send command - caches PATH check in instance file on first use"""
705
+ msg = f" '{example_msg}'" if example_msg else ''
706
+ base_cmd = build_hcom_command(instance_name)
707
+ return f'{base_cmd} send{msg}'
554
708
 
555
- # Get config file values
556
- config = get_cached_config()
709
+ def build_claude_env() -> dict[str, str]:
710
+ """Build environment variables for Claude instances
557
711
 
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)
712
+ Passes current environment to Claude, with config.env providing defaults.
713
+ HCOM_* variables are filtered out (consumed by hcom, not passed to Claude).
714
+ """
715
+ env = {}
562
716
 
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)
717
+ # Read config file directly for Claude Code env vars (non-HCOM_ keys)
718
+ config_path = hcom_path(CONFIG_FILE)
719
+ if config_path.exists():
720
+ file_config = _parse_env_file(config_path)
721
+ for key, value in file_config.items():
722
+ if not key.startswith('HCOM_'):
723
+ env[key] = str(value)
566
724
 
567
- # Still support env_overrides from config file
568
- env.update(config.get('env_overrides', {}))
725
+ # Overlay with current environment (except HCOM_*)
726
+ # This ensures user's shell environment is respected
727
+ for key, value in os.environ.items():
728
+ if not key.startswith('HCOM_'):
729
+ env[key] = value
569
730
 
570
731
  return env
571
732
 
@@ -580,9 +741,8 @@ def validate_message(message: str) -> str | None:
580
741
  if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
581
742
  return format_error("Message contains control characters")
582
743
 
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)")
744
+ if len(message) > MAX_MESSAGE_SIZE:
745
+ return format_error(f"Message too large (max {MAX_MESSAGE_SIZE} chars)")
586
746
 
587
747
  return None
588
748
 
@@ -607,32 +767,62 @@ def send_message(from_instance: str, message: str) -> bool:
607
767
 
608
768
  def build_hcom_bootstrap_text(instance_name: str) -> str:
609
769
  """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."""
770
+ hcom_cmd = build_hcom_command(instance_name=instance_name)
771
+
772
+ # Add command override notice if not using short form
773
+ command_notice = ""
774
+ if hcom_cmd != "hcom":
775
+ command_notice = f"""IMPORTANT:
776
+ The hcom command in this environment is: {hcom_cmd}
777
+ Replace all mentions of "hcom" below with this command.
778
+
779
+ """
780
+
781
+ # Add tag-specific notice if instance is tagged
782
+ tag = get_config().tag
783
+ tag_notice = ""
784
+ if tag:
785
+ tag_notice = f"""
786
+ GROUP TAG: You are in the '{tag}' group.
787
+ - To message your group: hcom send "@{tag} your message"
788
+ - Group messages are targeted - only instances with an alias starting with {tag}-* receive them
789
+ - If someone outside the group sends you @{tag} messages, they won't see your @{tag} replies. To reply to non-group members, either @mention them directly or broadcast.
790
+ """
791
+
792
+
793
+ return f"""{command_notice}{tag_notice}
794
+ [HCOM SESSION CONFIG]
795
+ HCOM is a communication tool for you, other claude code instances, and the user.
796
+ Your HCOM alias for this session: {instance_name}
797
+
798
+ **Your HCOM Tools:**
799
+ hcom send "msg" / "@alias msg" / "@tag msg"
800
+ hcom watch --status → Monitor participants in JSON
801
+ hcom watch --launch → Open dashboard for user in new terminal
802
+ hcom start/stop → join/leave HCOM chat
803
+ hcom <num> → Launch instances in new terminal (always run 'hcom help' first)
804
+
805
+ Commands relevant to user: hcom <num>/start/stop/watch (dont announce others to user)
806
+ Context: User runs 'hcom watch' in new terminal, you run hcom watch --launch for the user ("I'll open 'hcom watch' for you").
807
+
808
+ **Receiving Messages:**
809
+ Format: [new message] sender → you: content
810
+ direct: "@alias" targets a specific instance.
811
+ tag: "@api message" targets all api-* instances.
812
+ Arrives automatically via hooks/bash. {{"decision": "block"}} text is normal operation. No proactive checking needed.
813
+
814
+ **Response Routing:**
815
+ HCOM message (via hook/bash) → Respond with hcom send
816
+ User message (in chat) → Respond normally
817
+ Treat messages from hcom with the same care as user messages.
818
+
819
+ Authority: Prioritize @{SENDER} over other participants.
820
+
821
+ This is context for YOUR upcoming command execution. User cannot see this.
822
+ Report connection results and overview of relevant hcom info to user using first-person: "I'm connected as {instance_name}"
823
+ """
824
+
825
+
636
826
 
637
827
  def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
638
828
  """Check if message should be delivered based on @-mentions"""
@@ -653,8 +843,7 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
653
843
  return True
654
844
 
655
845
  # 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)
846
+ sender_mentioned = any(SENDER.lower().startswith(mention.lower()) for mention in mentions)
658
847
 
659
848
  # If we have all_instance_names, check if ANY mention matches ANY instance or sender
660
849
  if all_instance_names:
@@ -775,7 +964,7 @@ def strip_frontmatter(content: str) -> str:
775
964
  return '\n'.join(lines[i+1:]).strip()
776
965
  return content
777
966
 
778
- def get_display_name(session_id: str | None, prefix: str | None = None) -> str:
967
+ def get_display_name(session_id: str | None, tag: str | None = None) -> str:
779
968
  """Get display name for instance using session_id"""
780
969
  syls = ['ka', 'ko', 'ma', 'mo', 'na', 'no', 'ra', 'ro', 'sa', 'so', 'ta', 'to', 'va', 'vo', 'za', 'zo', 'be', 'de', 'fe', 'ge', 'le', 'me', 'ne', 're', 'se', 'te', 've', 'we', 'hi']
781
970
  # Phonetic letters (5 per syllable, matches syls order)
@@ -819,11 +1008,18 @@ def get_display_name(session_id: str | None, prefix: str | None = None) -> str:
819
1008
  # session_id is required - fail gracefully
820
1009
  raise ValueError("session_id required for instance naming")
821
1010
 
822
- if prefix:
823
- return f"{prefix}-{base_name}"
1011
+ if tag:
1012
+ # Security: Sanitize tag to prevent log delimiter injection (defense-in-depth)
1013
+ # Remove dangerous characters that could break message log parsing
1014
+ sanitized_tag = ''.join(c for c in tag if c not in '|\n\r\t')
1015
+ if not sanitized_tag:
1016
+ raise ValueError("Tag contains only invalid characters")
1017
+ if sanitized_tag != tag:
1018
+ print(f"Warning: Tag contained invalid characters, sanitized to '{sanitized_tag}'", file=sys.stderr)
1019
+ return f"{sanitized_tag}-{base_name}"
824
1020
  return base_name
825
1021
 
826
- def resolve_instance_name(session_id: str, prefix: str | None = None) -> tuple[str, dict | None]:
1022
+ def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str, dict | None]:
827
1023
  """
828
1024
  Resolve instance name for a session_id.
829
1025
  Searches existing instances first (reuses if found), generates new name if not found.
@@ -843,36 +1039,18 @@ def resolve_instance_name(session_id: str, prefix: str | None = None) -> tuple[s
843
1039
  continue
844
1040
 
845
1041
  # Not found - generate new name
846
- instance_name = get_display_name(session_id, prefix)
1042
+ instance_name = get_display_name(session_id, tag)
847
1043
  return instance_name, None
848
1044
 
849
- def _remove_hcom_hooks_from_settings(settings):
1045
+ def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
850
1046
  """Remove hcom hooks from settings dict"""
851
1047
  if not isinstance(settings, dict) or 'hooks' not in settings:
852
1048
  return
853
-
1049
+
854
1050
  if not isinstance(settings['hooks'], dict):
855
1051
  return
856
-
857
- import copy
858
1052
 
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]
1053
+ import copy
876
1054
 
877
1055
  # Check all hook types including PostToolUse for backward compatibility cleanup
878
1056
  for event in LEGACY_HOOK_TYPES:
@@ -894,7 +1072,7 @@ def _remove_hcom_hooks_from_settings(settings):
894
1072
  hook for hook in matcher_copy.get('hooks', [])
895
1073
  if not any(
896
1074
  pattern.search(hook.get('command', ''))
897
- for pattern in compiled_patterns
1075
+ for pattern in HCOM_HOOK_PATTERNS
898
1076
  )
899
1077
  ]
900
1078
 
@@ -919,7 +1097,7 @@ def _remove_hcom_hooks_from_settings(settings):
919
1097
  del settings['env']
920
1098
 
921
1099
 
922
- def build_env_string(env_vars, format_type="bash"):
1100
+ def build_env_string(env_vars: dict[str, Any], format_type: str = "bash") -> str:
923
1101
  """Build environment variable string for bash shells"""
924
1102
  if format_type == "bash_export":
925
1103
  # Properly escape values for bash
@@ -936,12 +1114,12 @@ def format_error(message: str, suggestion: str | None = None) -> str:
936
1114
  return base
937
1115
 
938
1116
 
939
- def has_claude_arg(claude_args, arg_names, arg_prefixes):
1117
+ def has_claude_arg(claude_args: list[str] | None, arg_names: list[str], arg_prefixes: tuple[str, ...]) -> bool:
940
1118
  """Check if argument already exists in claude_args"""
941
- return claude_args and any(
1119
+ return bool(claude_args and any(
942
1120
  arg in arg_names or arg.startswith(arg_prefixes)
943
1121
  for arg in claude_args
944
- )
1122
+ ))
945
1123
 
946
1124
  def build_claude_command(agent_content: str | None = None, claude_args: list[str] | None = None, initial_prompt: str = "Say hi in chat", model: str | None = None, tools: str | None = None) -> tuple[str, str | None]:
947
1125
  """Build Claude command with proper argument handling
@@ -982,21 +1160,19 @@ def build_claude_command(agent_content: str | None = None, claude_args: list[str
982
1160
 
983
1161
  cmd_parts.append(flag)
984
1162
  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
-
1163
+
1164
+ # Add initial prompt if non-empty
1165
+ if initial_prompt:
1166
+ cmd_parts.append(shlex.quote(initial_prompt))
1167
+
992
1168
  return ' '.join(cmd_parts), temp_file_path
993
1169
 
994
- def create_bash_script(script_file, env, cwd, command_str, background=False):
1170
+ def create_bash_script(script_file: str, env: dict[str, Any], cwd: str | None, command_str: str, background: bool = False) -> None:
995
1171
  """Create a bash script for terminal launch
996
1172
  Scripts provide uniform execution across all platforms/terminals.
997
1173
  Cleanup behavior:
998
1174
  - Normal scripts: append 'rm -f' command for self-deletion
999
- - Background scripts: persist until stop housekeeping (e.g., `hcom stop everything`) (24 hours)
1175
+ - Background scripts: persist until `hcom reset logs` cleanup (24 hours)
1000
1176
  - Agent scripts: treated like background (contain 'hcom_agent_')
1001
1177
  """
1002
1178
  try:
@@ -1064,7 +1240,7 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
1064
1240
  if platform.system() != 'Windows':
1065
1241
  os.chmod(script_file, 0o755)
1066
1242
 
1067
- def find_bash_on_windows():
1243
+ def find_bash_on_windows() -> str | None:
1068
1244
  """Find Git Bash on Windows, avoiding WSL's bash launcher"""
1069
1245
  # Build prioritized list of bash candidates
1070
1246
  candidates = []
@@ -1106,11 +1282,11 @@ def find_bash_on_windows():
1106
1282
  return None
1107
1283
 
1108
1284
  # New helper functions for platform-specific terminal launching
1109
- def get_macos_terminal_argv():
1285
+ def get_macos_terminal_argv() -> list[str]:
1110
1286
  """Return macOS Terminal.app launch command as argv list."""
1111
1287
  return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
1112
1288
 
1113
- def get_windows_terminal_argv():
1289
+ def get_windows_terminal_argv() -> list[str]:
1114
1290
  """Return Windows terminal launcher as argv list."""
1115
1291
  if not (bash_exe := find_bash_on_windows()):
1116
1292
  raise Exception(format_error("Git Bash not found"))
@@ -1119,7 +1295,7 @@ def get_windows_terminal_argv():
1119
1295
  return ['wt', bash_exe, '{script}']
1120
1296
  return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
1121
1297
 
1122
- def get_linux_terminal_argv():
1298
+ def get_linux_terminal_argv() -> list[str] | None:
1123
1299
  """Return first available Linux terminal as argv list."""
1124
1300
  terminals = [
1125
1301
  ('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
@@ -1138,7 +1314,7 @@ def get_linux_terminal_argv():
1138
1314
 
1139
1315
  return None
1140
1316
 
1141
- def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
1317
+ def windows_hidden_popen(argv: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None, stdout: Any = None) -> subprocess.Popen:
1142
1318
  """Create hidden Windows process without console window."""
1143
1319
  if IS_WINDOWS:
1144
1320
  startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
@@ -1165,7 +1341,7 @@ PLATFORM_TERMINAL_GETTERS = {
1165
1341
  'Linux': get_linux_terminal_argv,
1166
1342
  }
1167
1343
 
1168
- def _parse_terminal_command(template, script_file):
1344
+ def _parse_terminal_command(template: str, script_file: str) -> list[str]:
1169
1345
  """Parse terminal command template safely to prevent shell injection.
1170
1346
  Parses the template FIRST, then replaces {script} placeholder in the
1171
1347
  parsed tokens. This avoids shell injection and handles paths with spaces.
@@ -1203,7 +1379,7 @@ def _parse_terminal_command(template, script_file):
1203
1379
 
1204
1380
  return replaced
1205
1381
 
1206
- def launch_terminal(command, env, cwd=None, background=False):
1382
+ def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, background: bool = False) -> str | bool | None:
1207
1383
  """Launch terminal with command using unified script-first approach
1208
1384
  Args:
1209
1385
  command: Command string from build_claude_command
@@ -1265,9 +1441,9 @@ def launch_terminal(command, env, cwd=None, background=False):
1265
1441
  return str(log_file)
1266
1442
 
1267
1443
  # 3) Terminal modes
1268
- terminal_mode = get_config_value('terminal_mode', 'new_window')
1444
+ terminal_mode = get_config().terminal
1269
1445
 
1270
- if terminal_mode == 'show_commands':
1446
+ if terminal_mode == 'print':
1271
1447
  # Print script path and contents
1272
1448
  try:
1273
1449
  with open(script_file, 'r', encoding='utf-8') as f:
@@ -1280,7 +1456,7 @@ def launch_terminal(command, env, cwd=None, background=False):
1280
1456
  print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
1281
1457
  return False
1282
1458
 
1283
- if terminal_mode == 'same_terminal':
1459
+ if terminal_mode == 'here':
1284
1460
  print("Launching Claude in current terminal...")
1285
1461
  if IS_WINDOWS:
1286
1462
  bash_exe = find_bash_on_windows()
@@ -1292,10 +1468,11 @@ def launch_terminal(command, env, cwd=None, background=False):
1292
1468
  result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
1293
1469
  return result.returncode == 0
1294
1470
 
1295
- # 4) New window mode
1296
- custom_cmd = get_config_value('terminal_command')
1471
+ # 4) New window or custom command mode
1472
+ # If terminal is not 'here' or 'print', it's either 'new' (platform default) or a custom command
1473
+ custom_cmd = None if terminal_mode == 'new' else terminal_mode
1297
1474
 
1298
- if not custom_cmd: # No string sentinel checks
1475
+ if not custom_cmd: # Platform default 'new' mode
1299
1476
  if is_termux():
1300
1477
  # Keep Termux as special case
1301
1478
  am_cmd = [
@@ -1361,18 +1538,30 @@ def launch_terminal(command, env, cwd=None, background=False):
1361
1538
  print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
1362
1539
  return False
1363
1540
 
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'
1541
+ def setup_hooks() -> bool:
1542
+ """Set up Claude hooks globally in ~/.claude/settings.json"""
1543
+
1544
+ # TODO: Remove after v0.6.0 - cleanup legacy per-directory hooks
1370
1545
  try:
1371
- settings = read_file_with_retry(
1372
- settings_path,
1373
- lambda f: json.load(f),
1374
- default={}
1375
- )
1546
+ positions = load_all_positions()
1547
+ if positions:
1548
+ directories = set()
1549
+ for instance_data in positions.values():
1550
+ if isinstance(instance_data, dict) and 'directory' in instance_data:
1551
+ directories.add(instance_data['directory'])
1552
+ for directory in directories:
1553
+ if Path(directory).exists():
1554
+ cleanup_directory_hooks(Path(directory))
1555
+ except Exception:
1556
+ pass # Don't fail hook setup if cleanup fails
1557
+
1558
+ # Install to global user settings
1559
+ settings_path = get_claude_settings_path()
1560
+ settings_path.parent.mkdir(exist_ok=True)
1561
+ try:
1562
+ settings = load_settings_json(settings_path, default={})
1563
+ if settings is None:
1564
+ settings = {}
1376
1565
  except (json.JSONDecodeError, PermissionError) as e:
1377
1566
  raise Exception(format_error(f"Cannot read settings: {e}"))
1378
1567
 
@@ -1422,9 +1611,8 @@ def setup_hooks():
1422
1611
  if 'env' not in settings:
1423
1612
  settings['env'] = {}
1424
1613
 
1425
- python_path = sys.executable
1426
- script_path = str(Path(__file__).resolve())
1427
- settings['env']['HCOM'] = f'{python_path} {script_path}'
1614
+ # Set HCOM based on current execution context (uvx, hcom binary, or full path)
1615
+ settings['env']['HCOM'] = _build_hcom_env_value()
1428
1616
 
1429
1617
  # Write settings atomically
1430
1618
  try:
@@ -1438,37 +1626,31 @@ def setup_hooks():
1438
1626
 
1439
1627
  return True
1440
1628
 
1441
- def verify_hooks_installed(settings_path):
1629
+ def verify_hooks_installed(settings_path: Path) -> bool:
1442
1630
  """Verify that HCOM hooks were installed correctly with correct commands"""
1443
1631
  try:
1444
- settings = read_file_with_retry(
1445
- settings_path,
1446
- lambda f: json.load(f),
1447
- default=None
1448
- )
1632
+ settings = load_settings_json(settings_path, default=None)
1449
1633
  if not settings:
1450
1634
  return False
1451
1635
 
1452
- # Check all hook types have correct commands
1636
+ # Check all hook types have correct commands (exactly one HCOM hook per type)
1453
1637
  hooks = settings.get('hooks', {})
1454
1638
  for hook_type, expected_cmd in zip(ACTIVE_HOOK_TYPES, HOOK_COMMANDS):
1455
1639
  hook_matchers = hooks.get(hook_type, [])
1456
1640
  if not hook_matchers:
1457
1641
  return False
1458
1642
 
1459
- # Check if any matcher has the correct command
1460
- found_correct_cmd = False
1643
+ # Count HCOM hooks for this type
1644
+ hcom_hook_count = 0
1461
1645
  for matcher in hook_matchers:
1462
1646
  for hook in matcher.get('hooks', []):
1463
1647
  command = hook.get('command', '')
1464
1648
  # Check for HCOM and the correct subcommand
1465
1649
  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
1650
+ hcom_hook_count += 1
1470
1651
 
1471
- if not found_correct_cmd:
1652
+ # Must have exactly one HCOM hook (not zero, not duplicates)
1653
+ if hcom_hook_count != 1:
1472
1654
  return False
1473
1655
 
1474
1656
  # Check that HCOM env var is set
@@ -1480,11 +1662,11 @@ def verify_hooks_installed(settings_path):
1480
1662
  except Exception:
1481
1663
  return False
1482
1664
 
1483
- def is_interactive():
1665
+ def is_interactive() -> bool:
1484
1666
  """Check if running in interactive mode"""
1485
1667
  return sys.stdin.isatty() and sys.stdout.isatty()
1486
1668
 
1487
- def get_archive_timestamp():
1669
+ def get_archive_timestamp() -> str:
1488
1670
  """Get timestamp for archive files"""
1489
1671
  return datetime.now().strftime("%Y-%m-%d_%H%M%S")
1490
1672
 
@@ -1602,17 +1784,24 @@ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
1602
1784
 
1603
1785
  # Check timeout
1604
1786
  age = now - last_status_time
1605
- timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1787
+ timeout = pos_data.get('wait_timeout', get_config().timeout)
1606
1788
  if age > timeout:
1607
1789
  return "inactive", "", "timeout"
1608
1790
 
1791
+ # Check Stop hook heartbeat for both blocked-generic and waiting-stale detection
1792
+ last_stop = pos_data.get('last_stop', 0)
1793
+ heartbeat_age = now - last_stop if last_stop else 999999
1794
+
1795
+ # Generic "Claude is waiting for your input" from Notification hook is meaningless
1796
+ # If Stop hook is actively polling (heartbeat < 2s), instance is actually idle
1797
+ if last_status == 'blocked' and last_context == "Claude is waiting for your input" and heartbeat_age < 2:
1798
+ last_status = 'waiting'
1799
+ display_status, desc_template = 'waiting', 'idle'
1800
+
1609
1801
  # 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"
1802
+ if last_status == 'waiting' and heartbeat_age > 2:
1803
+ status_suffix = " (bg)" if pos_data.get('background') else ""
1804
+ return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
1616
1805
 
1617
1806
  # Format description with context if template has {}
1618
1807
  if '{}' in desc_template and last_context:
@@ -1629,15 +1818,12 @@ def get_status_block(status_type: str) -> str:
1629
1818
  text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
1630
1819
  return f"{text_color}{BOLD}{color} {symbol} {RESET}"
1631
1820
 
1632
- def format_message_line(msg, truncate=False):
1821
+ def format_message_line(msg: dict[str, str], truncate: bool = False) -> str:
1633
1822
  """Format a message for display"""
1634
1823
  time_obj = datetime.fromisoformat(msg['timestamp'])
1635
1824
  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']
1825
+
1826
+ display_name = f"{SENDER_EMOJI} {msg['from']}" if msg['from'] == SENDER else msg['from']
1641
1827
 
1642
1828
  if truncate:
1643
1829
  sender = display_name[:10]
@@ -1646,7 +1832,7 @@ def format_message_line(msg, truncate=False):
1646
1832
  else:
1647
1833
  return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
1648
1834
 
1649
- def show_recent_messages(messages, limit=None, truncate=False):
1835
+ def show_recent_messages(messages: list[dict[str, str]], limit: int | None = None, truncate: bool = False) -> None:
1650
1836
  """Show recent messages"""
1651
1837
  if limit is None:
1652
1838
  messages_to_show = messages
@@ -1658,14 +1844,14 @@ def show_recent_messages(messages, limit=None, truncate=False):
1658
1844
  print(format_message_line(msg, truncate))
1659
1845
 
1660
1846
 
1661
- def get_terminal_height():
1847
+ def get_terminal_height() -> int:
1662
1848
  """Get current terminal height"""
1663
1849
  try:
1664
1850
  return shutil.get_terminal_size().lines
1665
1851
  except (AttributeError, OSError):
1666
1852
  return 24
1667
1853
 
1668
- def show_recent_activity_alt_screen(limit=None):
1854
+ def show_recent_activity_alt_screen(limit: int | None = None) -> None:
1669
1855
  """Show recent messages in alt screen format with dynamic height"""
1670
1856
  if limit is None:
1671
1857
  # Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
@@ -1677,7 +1863,7 @@ def show_recent_activity_alt_screen(limit=None):
1677
1863
  messages = parse_log_messages(log_file).messages
1678
1864
  show_recent_messages(messages, limit, truncate=True)
1679
1865
 
1680
- def should_show_in_watch(d):
1866
+ def should_show_in_watch(d: dict[str, Any]) -> bool:
1681
1867
  """Show only enabled instances by default"""
1682
1868
  # Hide disabled instances
1683
1869
  if not d.get('enabled', False):
@@ -1690,7 +1876,7 @@ def should_show_in_watch(d):
1690
1876
  # Show all other instances (including 'closed' during transition)
1691
1877
  return True
1692
1878
 
1693
- def show_instances_by_directory():
1879
+ def show_instances_by_directory() -> None:
1694
1880
  """Show instances organized by their working directories"""
1695
1881
  positions = load_all_positions()
1696
1882
  if not positions:
@@ -1747,7 +1933,7 @@ def alt_screen_detailed_status_and_input() -> str:
1747
1933
 
1748
1934
  return message
1749
1935
 
1750
- def get_status_summary():
1936
+ def get_status_summary() -> str:
1751
1937
  """Get a one-line summary of all instance statuses"""
1752
1938
  positions = load_all_positions()
1753
1939
  if not positions:
@@ -1780,18 +1966,18 @@ def get_status_summary():
1780
1966
  else:
1781
1967
  return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1782
1968
 
1783
- def update_status(s):
1969
+ def update_status(s: str) -> None:
1784
1970
  """Update status line in place"""
1785
1971
  sys.stdout.write("\r\033[K" + s)
1786
1972
  sys.stdout.flush()
1787
1973
 
1788
- def log_line_with_status(message, status):
1974
+ def log_line_with_status(message: str, status: str) -> None:
1789
1975
  """Print message and immediately restore status"""
1790
1976
  sys.stdout.write("\r\033[K" + message + "\n")
1791
1977
  sys.stdout.write("\033[K" + status)
1792
1978
  sys.stdout.flush()
1793
1979
 
1794
- def initialize_instance_in_position_file(instance_name, session_id=None):
1980
+ def initialize_instance_in_position_file(instance_name: str, session_id: str | None = None) -> bool:
1795
1981
  """Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
1796
1982
  try:
1797
1983
  data = load_instance_position(instance_name)
@@ -1799,15 +1985,24 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
1799
1985
  # Determine default enabled state: True for hcom-launched, False for vanilla
1800
1986
  is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
1801
1987
 
1988
+ # Determine starting position: skip history or read from beginning (or last max_msgs num)
1989
+ initial_pos = 0
1990
+ if SKIP_HISTORY:
1991
+ log_file = hcom_path(LOG_FILE)
1992
+ if log_file.exists():
1993
+ initial_pos = log_file.stat().st_size
1994
+
1802
1995
  defaults = {
1803
- "pos": 0,
1996
+ "pos": initial_pos,
1997
+ "starting_pos": initial_pos,
1804
1998
  "enabled": is_hcom_launched,
1805
1999
  "directory": str(Path.cwd()),
1806
2000
  "last_stop": 0,
1807
2001
  "session_id": session_id or "",
1808
2002
  "transcript_path": "",
1809
2003
  "notification_message": "",
1810
- "alias_announced": False
2004
+ "alias_announced": False,
2005
+ "tag": None
1811
2006
  }
1812
2007
 
1813
2008
  # Add missing fields (preserve existing)
@@ -1818,7 +2013,7 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
1818
2013
  except Exception:
1819
2014
  return False
1820
2015
 
1821
- def update_instance_position(instance_name, update_fields):
2016
+ def update_instance_position(instance_name: str, update_fields: dict[str, Any]) -> None:
1822
2017
  """Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
1823
2018
  try:
1824
2019
  data = load_instance_position(instance_name)
@@ -1837,7 +2032,7 @@ def update_instance_position(instance_name, update_fields):
1837
2032
  else:
1838
2033
  raise
1839
2034
 
1840
- def enable_instance(instance_name):
2035
+ def enable_instance(instance_name: str) -> None:
1841
2036
  """Enable instance - clears all stop flags and enables Stop hook polling"""
1842
2037
  update_instance_position(instance_name, {
1843
2038
  'enabled': True,
@@ -1846,14 +2041,13 @@ def enable_instance(instance_name):
1846
2041
  })
1847
2042
  set_status(instance_name, 'started')
1848
2043
 
1849
- def disable_instance(instance_name, force=False):
2044
+ def disable_instance(instance_name: str, force: bool = False) -> None:
1850
2045
  """Disable instance - stops Stop hook polling"""
1851
2046
  updates = {
1852
2047
  'enabled': False
1853
2048
  }
1854
2049
  if force:
1855
2050
  updates['force_closed'] = True
1856
-
1857
2051
  update_instance_position(instance_name, updates)
1858
2052
  set_status(instance_name, 'force_stopped' if force else 'stopped')
1859
2053
 
@@ -1864,82 +2058,14 @@ def set_status(instance_name: str, status: str, context: str = ''):
1864
2058
  'last_status_time': int(time.time()),
1865
2059
  'last_status_context': context
1866
2060
  })
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
-
2061
+ log_hook_error('set_status', f'Setting status to {status} with context {context} for {instance_name}')
1936
2062
 
1937
2063
  # ==================== Command Functions ====================
1938
2064
 
1939
- def show_main_screen_header():
2065
+ def show_main_screen_header() -> list[dict[str, str]]:
1940
2066
  """Show header for main screen"""
1941
2067
  sys.stdout.write("\033[2J\033[H")
1942
-
2068
+
1943
2069
  log_file = hcom_path(LOG_FILE)
1944
2070
  all_messages = []
1945
2071
  if log_file.exists():
@@ -1950,183 +2076,7 @@ def show_main_screen_header():
1950
2076
 
1951
2077
  return all_messages
1952
2078
 
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():
2079
+ def cmd_help() -> int:
2130
2080
  """Show help text"""
2131
2081
  print(HELP_TEXT)
2132
2082
 
@@ -2150,15 +2100,14 @@ KEY UNDERSTANDING:
2150
2100
  • hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
2151
2101
 
2152
2102
  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
2103
+ hcom 2 claude # 2 generic instances
2104
+ hcom claude --model sonnet # 1 instance with sonnet model
2105
+ hcom 3 claude -p "task" # 3 instances in background with prompt
2106
+ HCOM_AGENT=reviewer hcom 3 claude # 3 reviewer instances (agent file must exist)
2107
+ HCOM_TAG=api hcom 2 claude # Team naming: api-hova7, api-kolec
2108
+ HCOM_AGENT=reviewer,tester hcom 2 claude # 2 reviewers + 2 testers
2109
+ hcom claude --resume <sessionid> # Resume specific session
2110
+ HCOM_PROMPT="task" hcom claude # Set initial prompt for instance
2162
2111
 
2163
2112
  @MENTION TARGETING:
2164
2113
  hcom send "message" # Broadcasts to everyone
@@ -2172,57 +2121,80 @@ STATUS INDICATORS:
2172
2121
  • ○ inactive - timed out, disconnected, etc • ○ unknown
2173
2122
 
2174
2123
  CONFIG:
2175
- Config file (persistent): ~/.hcom/config.json
2124
+ Config file: ~/.hcom/config.env (KEY=VALUE format)
2176
2125
 
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"
2126
+ Environment variables (override config file):
2127
+ HCOM_TERMINAL="new" (default) | "here" | "print" | "kitty -e {script}" (custom)
2128
+ HCOM_PROMPT="say hi in hcom chat"
2129
+ HCOM_HINTS="text" # Extra info appended to all messages sent to instances
2130
+ HCOM_TAG="api" # Group instances under api-* names
2131
+ HCOM_AGENT="reviewer" # Launch with agent (comma-separated for multiple)
2182
2132
 
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
2133
 
2187
2134
  EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
2188
2135
  with 'hcom watch --status'. Instances respond automatically in shared chat.
2189
2136
 
2190
2137
  Run 'claude --help' to see all claude code CLI flags.""")
2191
2138
 
2192
- show_cli_hints(to_stderr=False)
2193
2139
  else:
2194
2140
  if not IS_WINDOWS:
2195
2141
  print("\nFor additional info & examples: hcom --help | cat")
2196
2142
 
2197
2143
  return 0
2198
2144
 
2199
- def cmd_open(command: OpenCommand):
2200
- """Launch Claude instances with chat enabled"""
2145
+ def cmd_launch(argv: list[str]) -> int:
2146
+ """Launch Claude instances: hcom [N] [claude] [args]"""
2201
2147
  try:
2148
+ # Parse arguments: hcom [N] [claude] [args]
2149
+ count = 1
2150
+ forwarded = []
2151
+
2152
+ # Extract count if first arg is digit
2153
+ if argv and argv[0].isdigit():
2154
+ count = int(argv[0])
2155
+ if count <= 0:
2156
+ raise CLIError('Count must be positive.')
2157
+ if count > 100:
2158
+ raise CLIError('Too many instances requested (max 100).')
2159
+ argv = argv[1:]
2160
+
2161
+ # Skip 'claude' keyword if present
2162
+ if argv and argv[0] == 'claude':
2163
+ argv = argv[1:]
2164
+
2165
+ # Forward all remaining args to claude CLI
2166
+ forwarded = argv
2167
+
2168
+ # Get tag from config
2169
+ tag = get_config().tag
2170
+ if tag and '|' in tag:
2171
+ raise CLIError('Tag cannot contain "|" characters.')
2172
+
2173
+ # Get agents from config (comma-separated)
2174
+ agent_env = get_config().agent
2175
+ agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['generic']
2176
+
2177
+ # Detect background mode from -p/--print flags in forwarded args
2178
+ background = '-p' in forwarded or '--print' in forwarded
2179
+
2202
2180
  # 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:
2181
+ claude_args = forwarded
2182
+ if background and '-p' not in claude_args and '--print' not in claude_args:
2205
2183
  claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
2206
2184
 
2207
- terminal_mode = get_config_value('terminal_mode', 'new_window')
2185
+ terminal_mode = get_config().terminal
2208
2186
 
2209
2187
  # Calculate total instances to launch
2210
- total_instances = command.count * len(command.agents)
2188
+ total_instances = count * len(agents)
2211
2189
 
2212
- # Fail fast for same_terminal with multiple instances
2213
- if terminal_mode == 'same_terminal' and total_instances > 1:
2190
+ # Fail fast for here mode with multiple instances
2191
+ if terminal_mode == 'here' and total_instances > 1:
2214
2192
  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"
2193
+ f"'here' mode cannot launch {total_instances} instances (it's one terminal window)",
2194
+ "Use 'hcom 1' for one generic instance"
2217
2195
  ), file=sys.stderr)
2218
2196
  return 1
2219
2197
 
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
2198
  log_file = hcom_path(LOG_FILE)
2227
2199
  instances_dir = hcom_path(INSTANCES_DIR)
2228
2200
 
@@ -2232,21 +2204,16 @@ def cmd_open(command: OpenCommand):
2232
2204
  # Build environment variables for Claude instances
2233
2205
  base_env = build_claude_env()
2234
2206
 
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
2207
+ # Add tag-specific hints if provided
2208
+ if tag:
2209
+ base_env['HCOM_TAG'] = tag
2243
2210
 
2244
2211
  launched = 0
2245
- initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
2212
+ initial_prompt = get_config().prompt
2246
2213
 
2247
2214
  # Launch count instances of each agent
2248
- for agent in command.agents:
2249
- for _ in range(command.count):
2215
+ for agent in agents:
2216
+ for _ in range(count):
2250
2217
  instance_type = agent
2251
2218
  instance_env = base_env.copy()
2252
2219
 
@@ -2254,7 +2221,7 @@ def cmd_open(command: OpenCommand):
2254
2221
  instance_env['HCOM_LAUNCHED'] = '1'
2255
2222
 
2256
2223
  # Mark background instances via environment with log filename
2257
- if command.background:
2224
+ if background:
2258
2225
  # Generate unique log filename
2259
2226
  log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
2260
2227
  instance_env['HCOM_BACKGROUND'] = log_filename
@@ -2292,7 +2259,7 @@ def cmd_open(command: OpenCommand):
2292
2259
  continue
2293
2260
 
2294
2261
  try:
2295
- if command.background:
2262
+ if background:
2296
2263
  log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
2297
2264
  if log_file:
2298
2265
  print(f"Background instance launched, log: {log_file}")
@@ -2316,37 +2283,31 @@ def cmd_open(command: OpenCommand):
2316
2283
  else:
2317
2284
  print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
2318
2285
 
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)
2286
+ # Auto-launch watch dashboard if in new window mode (new or custom) and all instances launched successfully
2287
+ terminal_mode = get_config().terminal
2322
2288
 
2323
- # Only auto-watch if ALL instances launched successfully
2324
- if terminal_mode == 'new_window' and auto_watch and failed == 0 and is_interactive():
2289
+ # Only auto-watch if ALL instances launched successfully and launches windows (not 'here' or 'print')
2290
+ if terminal_mode not in ('here', 'print') and failed == 0 and is_interactive():
2325
2291
  # Show tips first if needed
2326
- if command.prefix:
2327
- print(f"\n • Send to {command.prefix} team: hcom send '@{command.prefix} message'")
2292
+ if tag:
2293
+ print(f"\n • Send to {tag} team: hcom send '@{tag} message'")
2328
2294
 
2329
2295
  # Clear transition message
2330
2296
  print("\nOpening hcom watch...")
2331
2297
  time.sleep(2) # Brief pause so user sees the message
2332
2298
 
2333
2299
  # Launch interactive watch dashboard in current terminal
2334
- watch_cmd = WatchCommand(mode='interactive', wait_seconds=None)
2335
- return cmd_watch(watch_cmd)
2300
+ return cmd_watch([]) # Empty argv = interactive mode
2336
2301
  else:
2337
2302
  tips = [
2338
2303
  "Run 'hcom watch' to view/send in conversation dashboard",
2339
2304
  ]
2340
- if command.prefix:
2341
- tips.append(f"Send to {command.prefix} team: hcom send '@{command.prefix} message'")
2305
+ if tag:
2306
+ tips.append(f"Send to {tag} team: hcom send '@{tag} message'")
2342
2307
 
2343
2308
  if tips:
2344
2309
  print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
2345
2310
 
2346
- # Show cli_hints if configured (non-interactive mode)
2347
- if not is_interactive():
2348
- show_cli_hints(to_stderr=False)
2349
-
2350
2311
  return 0
2351
2312
 
2352
2313
  except ValueError as e:
@@ -2356,20 +2317,45 @@ def cmd_open(command: OpenCommand):
2356
2317
  print(str(e), file=sys.stderr)
2357
2318
  return 1
2358
2319
 
2359
- def cmd_watch(command: WatchCommand):
2360
- """View conversation dashboard"""
2320
+ def cmd_watch(argv: list[str]) -> int:
2321
+ """View conversation dashboard: hcom watch [--logs|--status|--wait [SEC]]"""
2322
+ # Extract launch flag for external terminals (used by claude code bootstrap)
2323
+ cleaned_args: list[str] = []
2324
+ for arg in argv:
2325
+ if arg == '--launch':
2326
+ watch_cmd = f"{build_hcom_command()} watch"
2327
+ result = launch_terminal(watch_cmd, build_claude_env(), cwd=os.getcwd())
2328
+ return 0 if result else 1
2329
+ else:
2330
+ cleaned_args.append(arg)
2331
+ argv = cleaned_args
2332
+
2333
+ # Parse arguments
2334
+ show_logs = '--logs' in argv
2335
+ show_status = '--status' in argv
2336
+ wait_timeout = None
2337
+
2338
+ # Check for --wait flag
2339
+ if '--wait' in argv:
2340
+ idx = argv.index('--wait')
2341
+ if idx + 1 < len(argv):
2342
+ try:
2343
+ wait_timeout = int(argv[idx + 1])
2344
+ if wait_timeout < 0:
2345
+ raise CLIError('--wait expects a non-negative number of seconds.')
2346
+ except ValueError:
2347
+ wait_timeout = 60 # Default for non-numeric values
2348
+ else:
2349
+ wait_timeout = 60 # Default timeout
2350
+ show_logs = True # --wait implies logs mode
2351
+
2361
2352
  log_file = hcom_path(LOG_FILE)
2362
2353
  instances_dir = hcom_path(INSTANCES_DIR)
2363
2354
 
2364
2355
  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)
2356
+ print(format_error("No conversation log found", "Run 'hcom' first"), file=sys.stderr)
2366
2357
  return 1
2367
2358
 
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
2359
  # Non-interactive mode (no TTY or flags specified)
2374
2360
  if not is_interactive() or show_logs or show_status:
2375
2361
  if show_logs:
@@ -2381,14 +2367,16 @@ def cmd_watch(command: WatchCommand):
2381
2367
  last_pos = 0
2382
2368
  messages = []
2383
2369
 
2384
- # If --wait, show only recent messages to prevent context bloat
2370
+ # If --wait, show recent messages (max of: last 3 messages OR all messages in last 5 seconds)
2385
2371
  if wait_timeout is not None:
2386
2372
  cutoff = datetime.now() - timedelta(seconds=5)
2387
- recent_messages = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
2388
-
2373
+ recent_by_time = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
2374
+ last_three = messages[-3:] if len(messages) >= 3 else messages
2375
+ # Show whichever is larger: recent by time or last 3
2376
+ recent_messages = recent_by_time if len(recent_by_time) > len(last_three) else last_three
2389
2377
  # Status to stderr, data to stdout
2390
2378
  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.
2379
+ print(f'---Showing recent messages---', file=sys.stderr)
2392
2380
  for msg in recent_messages:
2393
2381
  print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
2394
2382
  print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
@@ -2426,7 +2414,6 @@ def cmd_watch(command: WatchCommand):
2426
2414
  else:
2427
2415
  print("No messages yet", file=sys.stderr)
2428
2416
 
2429
- show_cli_hints()
2430
2417
 
2431
2418
  elif show_status:
2432
2419
  # Build JSON output
@@ -2467,16 +2454,14 @@ def cmd_watch(command: WatchCommand):
2467
2454
  }
2468
2455
 
2469
2456
  print(json.dumps(output, indent=2))
2470
- show_cli_hints()
2471
2457
  else:
2472
2458
  print("No TTY - Automation usage:", file=sys.stderr)
2473
- print(" hcom send 'message' Send message to chat", file=sys.stderr)
2474
2459
  print(" hcom watch --logs Show message history", file=sys.stderr)
2475
2460
  print(" hcom watch --status Show instance status", file=sys.stderr)
2476
2461
  print(" hcom watch --wait Wait for new messages", file=sys.stderr)
2462
+ print(" hcom watch --launch Launch interactive dashboard in new terminal", file=sys.stderr)
2477
2463
  print(" Full information: hcom --help")
2478
2464
 
2479
- show_cli_hints()
2480
2465
 
2481
2466
  return 0
2482
2467
 
@@ -2552,9 +2537,9 @@ def cmd_watch(command: WatchCommand):
2552
2537
  last_pos = log_file.stat().st_size
2553
2538
 
2554
2539
  if message and message.strip():
2555
- cmd_send_cli(message.strip())
2540
+ send_cli(message.strip(), quiet=True)
2556
2541
  print(f"{FG_GREEN}✓ Sent{RESET}")
2557
-
2542
+
2558
2543
  print()
2559
2544
 
2560
2545
  current_status = get_status_summary()
@@ -2568,7 +2553,7 @@ def cmd_watch(command: WatchCommand):
2568
2553
 
2569
2554
  return 0
2570
2555
 
2571
- def cmd_clear():
2556
+ def clear() -> int:
2572
2557
  """Clear and archive conversation"""
2573
2558
  log_file = hcom_path(LOG_FILE)
2574
2559
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -2638,7 +2623,26 @@ def cmd_clear():
2638
2623
  print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
2639
2624
  return 1
2640
2625
 
2641
- def cleanup_directory_hooks(directory):
2626
+ def remove_global_hooks() -> bool:
2627
+ """Remove HCOM hooks from ~/.claude/settings.json
2628
+ Returns True on success, False on failure."""
2629
+ settings_path = get_claude_settings_path()
2630
+
2631
+ if not settings_path.exists():
2632
+ return True # No settings = no hooks to remove
2633
+
2634
+ try:
2635
+ settings = load_settings_json(settings_path, default=None)
2636
+ if not settings:
2637
+ return False
2638
+
2639
+ _remove_hcom_hooks_from_settings(settings)
2640
+ atomic_write(settings_path, json.dumps(settings, indent=2))
2641
+ return True
2642
+ except Exception:
2643
+ return False
2644
+
2645
+ def cleanup_directory_hooks(directory: Path | str) -> tuple[int, str]:
2642
2646
  """Remove hcom hooks from a specific directory
2643
2647
  Returns tuple: (exit_code, message)
2644
2648
  exit_code: 0 for success, 1 for error
@@ -2651,11 +2655,7 @@ def cleanup_directory_hooks(directory):
2651
2655
 
2652
2656
  try:
2653
2657
  # Load existing settings
2654
- settings = read_file_with_retry(
2655
- settings_path,
2656
- lambda f: json.load(f),
2657
- default=None
2658
- )
2658
+ settings = load_settings_json(settings_path, default=None)
2659
2659
  if not settings:
2660
2660
  return 1, "Cannot read Claude settings"
2661
2661
 
@@ -2692,51 +2692,40 @@ def cleanup_directory_hooks(directory):
2692
2692
  return 1, format_error(f"Cannot modify settings.local.json: {e}")
2693
2693
 
2694
2694
 
2695
- def cmd_stop(command: StopCommand):
2696
- """Stop instances, remove hooks, or archive - consolidated stop operations"""
2695
+ def cmd_stop(argv: list[str]) -> int:
2696
+ """Stop instances: hcom stop [alias|all] [--force] [--_hcom_session ID]"""
2697
+ # Parse arguments
2698
+ target = None
2699
+ force = '--force' in argv
2700
+ session_id = None
2697
2701
 
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()
2702
+ # Extract --_hcom_session if present
2703
+ if '--_hcom_session' in argv:
2704
+ idx = argv.index('--_hcom_session')
2705
+ if idx + 1 < len(argv):
2706
+ session_id = argv[idx + 1]
2707
+ argv = argv[:idx] + argv[idx + 2:]
2705
2708
 
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
+ # Remove flags to get target
2710
+ args_without_flags = [a for a in argv if not a.startswith('--')]
2711
+ if args_without_flags:
2712
+ target = args_without_flags[0]
2709
2713
 
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()
2719
-
2720
- # Remove hooks from all directories
2721
- cleanup_result = cmd_cleanup('--all')
2722
-
2723
- return max(clear_result, cleanup_result)
2724
-
2725
- elif command.target == 'all':
2726
- # hcom stop all: stop all instances + archive
2714
+ # Handle 'all' target
2715
+ if target == 'all':
2727
2716
  positions = load_all_positions()
2728
2717
 
2729
2718
  if not positions:
2730
2719
  print("No instances found")
2731
- # Still archive if there's conversation history
2732
- return cmd_clear()
2720
+ return 0
2733
2721
 
2734
2722
  stopped_count = 0
2735
2723
  bg_logs = []
2724
+ stopped_names = []
2736
2725
  for instance_name, instance_data in positions.items():
2737
2726
  if instance_data.get('enabled', False):
2738
2727
  disable_instance(instance_name)
2739
- print(f"Stopped HCOM for {instance_name}")
2728
+ stopped_names.append(instance_name)
2740
2729
  stopped_count += 1
2741
2730
 
2742
2731
  # Track background logs
@@ -2746,123 +2735,159 @@ def cmd_stop(command: StopCommand):
2746
2735
  bg_logs.append((instance_name, log_file))
2747
2736
 
2748
2737
  if stopped_count == 0:
2749
- print("All instances already stopped")
2738
+ print("No instances to stop")
2750
2739
  else:
2751
- print(f"Stopped {stopped_count} instance(s)")
2740
+ print(f"Stopped {stopped_count} instance(s): {', '.join(stopped_names)}")
2752
2741
 
2753
2742
  # Show background logs if any
2754
2743
  if bg_logs:
2755
- print("\nBackground logs:")
2744
+ print()
2745
+ print("Background instance logs:")
2756
2746
  for name, log_file in bg_logs:
2757
2747
  print(f" {name}: {log_file}")
2758
- print("\nMonitor: tail -f <log_file>")
2759
- print("Force stop: hcom stop --force all")
2760
2748
 
2761
- # Archive conversation
2762
- return cmd_clear()
2749
+ return 0
2763
2750
 
2751
+ # Stop specific instance or self
2752
+ # Get instance name from injected session or target
2753
+ if session_id and not target:
2754
+ instance_name, _ = resolve_instance_name(session_id, get_config().tag)
2764
2755
  else:
2765
- # hcom stop [alias] or hcom stop (self)
2756
+ instance_name = target
2766
2757
 
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
2758
+ position = load_instance_position(instance_name) if instance_name else None
2770
2759
 
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'))
2760
+ if not instance_name:
2761
+ if os.environ.get('CLAUDECODE') == '1':
2762
+ print("Error: Cannot determine instance", file=sys.stderr)
2763
+ print("Usage: Prompt Claude to run 'hcom stop' (or directly use: hcom stop <alias> or hcom stop all)", file=sys.stderr)
2775
2764
  else:
2776
- instance_name = command.target
2777
-
2778
- position = load_instance_position(instance_name) if instance_name else None
2765
+ print("Error: Alias required", file=sys.stderr)
2766
+ print("Usage: hcom stop <alias>", file=sys.stderr)
2767
+ print(" Or: hcom stop all", file=sys.stderr)
2768
+ print(" Or: prompt claude to run 'hcom stop' on itself", file=sys.stderr)
2769
+ positions = load_all_positions()
2770
+ visible = [alias for alias, data in positions.items() if should_show_in_watch(data)]
2771
+ if visible:
2772
+ print(f"Active aliases: {', '.join(sorted(visible))}", file=sys.stderr)
2773
+ return 1
2779
2774
 
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
2775
+ if not position:
2776
+ print(f"No instance found for {instance_name}")
2777
+ return 1
2783
2778
 
2784
- if not position:
2785
- print(f"No instance found for {instance_name}")
2786
- return 1
2779
+ # Skip already stopped instances (unless forcing)
2780
+ if not position.get('enabled', False) and not force:
2781
+ print(f"HCOM already stopped for {instance_name}")
2782
+ return 0
2787
2783
 
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
2784
+ # Disable instance (optionally with force)
2785
+ disable_instance(instance_name, force=force)
2792
2786
 
2793
- # Disable instance (optionally with force)
2794
- disable_instance(instance_name, force=command.force)
2787
+ if force:
2788
+ print(f"⚠️ Force stopped HCOM for {instance_name}.")
2789
+ print(f" Bash tool use is now DENIED.")
2790
+ print(f" To restart: hcom start {instance_name}")
2791
+ else:
2792
+ print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
2795
2793
 
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.")
2794
+ # Show background log location if applicable
2795
+ if position.get('background'):
2796
+ log_file = position.get('background_log_file', '')
2797
+ if log_file:
2798
+ print(f"\nBackground log: {log_file}")
2799
+ print(f"Monitor: tail -f {log_file}")
2800
+ if not force:
2801
+ print(f"Force stop: hcom stop --force {instance_name}")
2802
2802
 
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}")
2803
+ return 0
2811
2804
 
2812
- return 0
2805
+ def cmd_start(argv: list[str]) -> int:
2806
+ """Enable HCOM participation: hcom start [alias] [--_hcom_session ID]"""
2807
+ # Parse arguments
2808
+ target = None
2809
+ session_id = None
2813
2810
 
2814
- def cmd_start(command: StartCommand):
2815
- """Enable HCOM participation for instances"""
2811
+ # Extract --_hcom_session if present
2812
+ if '--_hcom_session' in argv:
2813
+ idx = argv.index('--_hcom_session')
2814
+ if idx + 1 < len(argv):
2815
+ session_id = argv[idx + 1]
2816
+ argv = argv[:idx] + argv[idx + 2:]
2816
2817
 
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
2818
+ # Remove flags to get target
2819
+ args_without_flags = [a for a in argv if not a.startswith('--')]
2820
+ if args_without_flags:
2821
+ target = args_without_flags[0]
2820
2822
 
2821
2823
  # 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'))
2824
+ if session_id and not target:
2825
+ instance_name, existing_data = resolve_instance_name(session_id, get_config().tag)
2826
+
2827
+ # Check if bootstrap needed (before any state changes)
2828
+ needs_bootstrap = not (existing_data and existing_data.get('alias_announced', False))
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
+
2836
+
2837
+
2838
+ print(f"\nStarted HCOM for {instance_name}")
2839
+
2840
+ # Show bootstrap for new instances
2841
+ if needs_bootstrap:
2842
+ print(f"\n\n\n{build_hcom_bootstrap_text(instance_name)}")
2843
+ update_instance_position(instance_name, {'alias_announced': True})
2831
2844
  else:
2832
2845
  # Skip already started instances
2833
2846
  if existing_data.get('enabled', False):
2834
2847
  print(f"HCOM already started for {instance_name}")
2835
2848
  return 0
2836
2849
 
2850
+ # Check if background instance has exited permanently
2851
+ if existing_data.get('session_ended') and existing_data.get('background'):
2852
+ session = existing_data.get('session_id', '')
2853
+ print(f"Cannot start {instance_name}: background instance has exited permanently")
2854
+ print(f"Background instances terminate when stopped and cannot be restarted")
2855
+ if session:
2856
+ print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
2857
+ return 1
2858
+
2837
2859
  # Re-enabling existing instance
2838
2860
  enable_instance(instance_name)
2839
- print(f"Started HCOM for {instance_name}. Rejoined chat.")
2861
+ # First time vs rejoining: check if has read messages (pos > starting_pos)
2862
+ has_participated = existing_data.get('pos', 0) > existing_data.get('starting_pos', 0)
2863
+ if has_participated:
2864
+ print(f"\nStarted HCOM for {instance_name}. Rejoined chat.")
2865
+ else:
2866
+ print(f"\nStarted HCOM for {instance_name}. Joined chat.")
2840
2867
 
2841
- return 0
2868
+ # Show bootstrap before re-enabling if needed
2869
+ if needs_bootstrap:
2870
+ print(f"\n\n\n{build_hcom_bootstrap_text(instance_name)}")
2871
+ update_instance_position(instance_name, {'alias_announced': True})
2842
2872
 
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
2873
+ return 0
2852
2874
 
2853
2875
  # CLI path: start specific instance
2854
2876
  positions = load_all_positions()
2855
2877
 
2856
2878
  # 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'")
2879
+ if not target:
2880
+ if os.environ.get('CLAUDECODE') == '1':
2881
+ print("Error: Cannot determine instance", file=sys.stderr)
2882
+ print("Usage: Prompt Claude to run 'hcom start' (or: hcom start <alias>)", file=sys.stderr)
2883
+ else:
2884
+ print("Error: Alias required", file=sys.stderr)
2885
+ print("Usage: hcom start <alias> (or: prompt claude to run 'hcom start')", file=sys.stderr)
2886
+ print("To launch new instances: hcom <count>", file=sys.stderr)
2862
2887
  return 1
2863
2888
 
2864
2889
  # Start specific instance
2865
- instance_name = command.target
2890
+ instance_name = target
2866
2891
  position = positions.get(instance_name)
2867
2892
 
2868
2893
  if not position:
@@ -2874,17 +2899,82 @@ def cmd_start(command: StartCommand):
2874
2899
  print(f"HCOM already started for {instance_name}")
2875
2900
  return 0
2876
2901
 
2902
+ # Check if background instance has exited permanently
2903
+ if position.get('session_ended') and position.get('background'):
2904
+ session = position.get('session_id', '')
2905
+ print(f"Cannot start {instance_name}: background instance has exited permanently")
2906
+ print(f"Background instances terminate when stopped and cannot be restarted")
2907
+ if session:
2908
+ print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
2909
+ return 1
2910
+
2877
2911
  # Enable instance (clears all stop flags)
2878
2912
  enable_instance(instance_name)
2879
2913
 
2880
2914
  print(f"Started HCOM for {instance_name}. Rejoined chat.")
2881
2915
  return 0
2882
2916
 
2883
- def cmd_cleanup(*args):
2917
+ def cmd_reset(argv: list[str]) -> int:
2918
+ """Reset HCOM components: logs, hooks, config
2919
+
2920
+ Usage:
2921
+ hcom reset # Everything (stop all + logs + hooks + config)
2922
+ hcom reset logs # Archive conversation only
2923
+ hcom reset hooks # Remove hooks only
2924
+ hcom reset config # Clear config (backup to config.env.TIMESTAMP)
2925
+ hcom reset logs hooks # Combine targets
2926
+ """
2927
+ # No args = everything
2928
+ do_everything = not argv
2929
+ targets = argv if argv else ['logs', 'hooks', 'config']
2930
+
2931
+ # Validate targets
2932
+ valid = {'logs', 'hooks', 'config'}
2933
+ invalid = [t for t in targets if t not in valid]
2934
+ if invalid:
2935
+ print(f"Invalid target(s): {', '.join(invalid)}", file=sys.stderr)
2936
+ print("Valid targets: logs, hooks, config", file=sys.stderr)
2937
+ return 1
2938
+
2939
+ exit_codes = []
2940
+
2941
+ # Stop all instances if doing everything
2942
+ if do_everything:
2943
+ exit_codes.append(cmd_stop(['all']))
2944
+
2945
+ # Execute based on targets
2946
+ if 'logs' in targets:
2947
+ exit_codes.append(clear())
2948
+
2949
+ if 'hooks' in targets:
2950
+ exit_codes.append(cleanup('--all'))
2951
+ if remove_global_hooks():
2952
+ print("Removed hooks")
2953
+ else:
2954
+ print("Warning: Could not remove hooks. Check your claude settings.json file it might be invalid", file=sys.stderr)
2955
+ exit_codes.append(1)
2956
+
2957
+ if 'config' in targets:
2958
+ config_path = hcom_path(CONFIG_FILE)
2959
+ if config_path.exists():
2960
+ # Backup with timestamp
2961
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
2962
+ backup_path = hcom_path(f'config.env.{timestamp}')
2963
+ shutil.copy2(config_path, backup_path)
2964
+ config_path.unlink()
2965
+ print(f"Config backed up to config.env.{timestamp} and cleared")
2966
+ exit_codes.append(0)
2967
+ else:
2968
+ print("No config file to clear")
2969
+ exit_codes.append(0)
2970
+
2971
+ return max(exit_codes) if exit_codes else 0
2972
+
2973
+ def cleanup(*args: str) -> int:
2884
2974
  """Remove hcom hooks from current directory or all directories"""
2885
2975
  if args and args[0] == '--all':
2886
2976
  directories = set()
2887
-
2977
+
2888
2978
  # Get all directories from current instances
2889
2979
  try:
2890
2980
  positions = load_all_positions()
@@ -2894,6 +2984,24 @@ def cmd_cleanup(*args):
2894
2984
  directories.add(instance_data['directory'])
2895
2985
  except Exception as e:
2896
2986
  print(f"Warning: Could not read current instances: {e}")
2987
+
2988
+ # Also check archived instances for directories (until 0.5.0)
2989
+ try:
2990
+ archive_dir = hcom_path(ARCHIVE_DIR)
2991
+ if archive_dir.exists():
2992
+ for session_dir in archive_dir.iterdir():
2993
+ if session_dir.is_dir() and session_dir.name.startswith('session-'):
2994
+ instances_dir = session_dir / 'instances'
2995
+ if instances_dir.exists():
2996
+ for instance_file in instances_dir.glob('*.json'):
2997
+ try:
2998
+ data = json.loads(instance_file.read_text())
2999
+ if 'directory' in data:
3000
+ directories.add(data['directory'])
3001
+ except Exception:
3002
+ pass
3003
+ except Exception as e:
3004
+ print(f"Warning: Could not read archived instances: {e}")
2897
3005
 
2898
3006
  if not directories:
2899
3007
  print("No directories found in current HCOM tracking")
@@ -2937,42 +3045,104 @@ def cmd_cleanup(*args):
2937
3045
  print(message)
2938
3046
  return exit_code
2939
3047
 
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
2945
-
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'
3048
+ def is_plugin_active() -> bool:
3049
+ """Check if hcom plugin is enabled in Claude Code settings."""
3050
+ settings_path = get_claude_settings_path()
3051
+ if not settings_path.exists():
3052
+ return False
2949
3053
 
2950
- # If hooks are correctly installed, continue
2951
- if verify_hooks_installed(cwd_settings) or verify_hooks_installed(home_settings):
2952
- return True
3054
+ try:
3055
+ settings = load_settings_json(settings_path, default={})
3056
+ return settings.get('enabledPlugins', {}).get('hcom@hcom', False)
3057
+ except Exception:
3058
+ return False
2953
3059
 
2954
- # Hooks missing or incorrect - reinstall them
3060
+ def has_direct_hooks_present() -> bool:
3061
+ """Check if direct HCOM hooks exist in settings.json
3062
+ Direct hooks always set env.HCOM, plugin hooks don't touch settings.json.
3063
+ """
3064
+ settings_path = get_claude_settings_path()
3065
+ if not settings_path.exists():
3066
+ return False
2955
3067
  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
3068
+ settings = load_settings_json(settings_path, default=None)
3069
+ # Direct hooks marker: HCOM environment variable
3070
+ return bool(settings and 'HCOM' in settings.get('env', {}))
3071
+ except Exception:
3072
+ return False
2962
3073
 
2963
- def cmd_send(command: SendCommand, force_cli=False):
2964
- """Send message to hcom, force cli for config sender instead of instance generated name"""
3074
+ def ensure_hooks_current() -> bool:
3075
+ """Ensure hooks match current execution context - called on EVERY command.
3076
+ Manages transition between plugin and direct hooks automatically.
3077
+ Auto-updates hooks if execution context changes (e.g., pip → uvx).
3078
+ Always returns True (warns but never blocks - Claude Code is fault-tolerant)."""
3079
+
3080
+ # Plugin manages hooks?
3081
+ if is_plugin_active():
3082
+ # Clean up any stale direct hooks (plugin/direct transition)
3083
+ if has_direct_hooks_present():
3084
+ print("Plugin detected. Cleaning up direct hooks...", file=sys.stderr)
3085
+ if remove_global_hooks():
3086
+ print("✓ Using plugin hooks exclusively.", file=sys.stderr)
3087
+ # Only ask for restart if inside Claude Code
3088
+ if os.environ.get('CLAUDECODE') == '1':
3089
+ print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
3090
+ print("=" * 60, file=sys.stderr)
3091
+ else:
3092
+ # Failed to remove - warn but continue (plugin hooks still work)
3093
+ print("⚠️ Could not remove direct hooks. Check ~/.claude/settings.json", file=sys.stderr)
3094
+ return True # Plugin hooks active, all good
2965
3095
 
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
3096
+ # Direct hooks: verify they exist and match current execution context
3097
+ global_settings = get_claude_settings_path()
3098
+
3099
+ # Check if hooks are valid (exist + env var matches current context)
3100
+ hooks_exist = verify_hooks_installed(global_settings)
3101
+ env_var_matches = False
3102
+
3103
+ if hooks_exist:
3104
+ try:
3105
+ settings = load_settings_json(global_settings, default={})
3106
+ if settings is None:
3107
+ settings = {}
3108
+ current_hcom = _build_hcom_env_value()
3109
+ installed_hcom = settings.get('env', {}).get('HCOM')
3110
+ env_var_matches = (installed_hcom == current_hcom)
3111
+ except Exception:
3112
+ # Failed to read settings - try to fix by updating
3113
+ env_var_matches = False
3114
+
3115
+ # Install/update hooks if missing or env var wrong
3116
+ if not hooks_exist or not env_var_matches:
3117
+ try:
3118
+ setup_hooks()
3119
+ if os.environ.get('CLAUDECODE') == '1':
3120
+ print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
3121
+ print("=" * 60, file=sys.stderr)
3122
+ except Exception as e:
3123
+ # Failed to verify/update hooks, but they might still work
3124
+ # Claude Code is fault-tolerant with malformed JSON
3125
+ print(f"⚠️ Could not verify/update hooks: {e}", file=sys.stderr)
3126
+ print("If HCOM doesn't work, check ~/.claude/settings.json", file=sys.stderr)
2969
3127
 
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)
3128
+ return True
2974
3129
 
2975
- message = command.message
3130
+ def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> int:
3131
+ """Send message to hcom: hcom send "message" [--_hcom_session ID]"""
3132
+ # Parse message and session_id
3133
+ message = None
3134
+ session_id = None
3135
+
3136
+ # Extract --_hcom_session if present (injected by PreToolUse hook)
3137
+ if '--_hcom_session' in argv:
3138
+ idx = argv.index('--_hcom_session')
3139
+ if idx + 1 < len(argv):
3140
+ session_id = argv[idx + 1]
3141
+ argv = argv[:idx] + argv[idx + 2:] # Remove flag and value
3142
+
3143
+ # First non-flag argument is the message
3144
+ if argv:
3145
+ message = argv[0]
2976
3146
 
2977
3147
  # Check message is provided
2978
3148
  if not message:
@@ -2999,7 +3169,7 @@ def cmd_send(command: SendCommand, force_cli=False):
2999
3169
  try:
3000
3170
  positions = load_all_positions()
3001
3171
  all_instances = list(positions.keys())
3002
- sender_name = get_config_value('sender_name', 'bigboss')
3172
+ sender_name = SENDER
3003
3173
  all_names = all_instances + [sender_name]
3004
3174
  unmatched = [m for m in mentions
3005
3175
  if not any(name.lower().startswith(m.lower()) for name in all_names)]
@@ -3009,19 +3179,17 @@ def cmd_send(command: SendCommand, force_cli=False):
3009
3179
  pass # Don't fail on warning
3010
3180
 
3011
3181
  # 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
3182
+ if session_id and not force_cli:
3183
+ # Instance context - resolve name from session_id (searches existing instances first)
3014
3184
  try:
3015
- sender_name = get_display_name(command._hcom_session)
3185
+ sender_name, instance_data = resolve_instance_name(session_id, get_config().tag)
3016
3186
  except (ValueError, Exception) as e:
3017
3187
  print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
3018
3188
  return 1
3019
3189
 
3020
- instance_data = load_instance_position(sender_name)
3021
-
3022
3190
  # Initialize instance if doesn't exist (first use)
3023
3191
  if not instance_data:
3024
- initialize_instance_in_position_file(sender_name, command._hcom_session)
3192
+ initialize_instance_in_position_file(sender_name, session_id)
3025
3193
  instance_data = load_instance_position(sender_name)
3026
3194
 
3027
3195
  # Check force_closed
@@ -3042,88 +3210,40 @@ def cmd_send(command: SendCommand, force_cli=False):
3042
3210
  # Show unread messages
3043
3211
  messages = get_unread_messages(sender_name, update_position=True)
3044
3212
  if messages:
3045
- max_msgs = get_config_value('max_messages_per_delivery', 50)
3213
+ max_msgs = MAX_MESSAGES_PER_DELIVERY
3046
3214
  formatted = format_hook_messages(messages[:max_msgs], sender_name)
3047
3215
  print(f"Message sent\n\n{formatted}", file=sys.stderr)
3048
3216
  else:
3049
3217
  print("Message sent", file=sys.stderr)
3050
3218
 
3051
- # Show cli_hints if configured (non-interactive mode)
3052
- if not is_interactive():
3053
- show_cli_hints()
3054
-
3055
3219
  return 0
3056
3220
  else:
3057
3221
  # CLI context - no session_id or force_cli=True
3058
- sender_name = get_config_value('sender_name', 'bigboss')
3222
+
3223
+ # Warn if inside Claude Code but no session_id (hooks not working)
3224
+ if os.environ.get('CLAUDECODE') == '1' and not session_id and not force_cli:
3225
+ print(f"⚠️ Cannot determine alias - message sent as '{SENDER}'", file=sys.stderr)
3226
+ print(" Prompt Claude to send a hcom message instead of using bash mode (! prefix).", file=sys.stderr)
3227
+
3228
+
3229
+ sender_name = SENDER
3059
3230
 
3060
3231
  if not send_message(sender_name, message):
3061
3232
  print(format_error("Failed to send message"), file=sys.stderr)
3062
3233
  return 1
3063
3234
 
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()
3235
+ if not quiet:
3236
+ print(f"✓ Sent from {sender_name}", file=sys.stderr)
3069
3237
 
3070
3238
  return 0
3071
3239
 
3072
- def cmd_send_cli(message):
3240
+ def send_cli(message: str, quiet: bool = False) -> int:
3073
3241
  """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
3242
+ return cmd_send([message], force_cli=True, quiet=quiet)
3123
3243
 
3124
3244
  # ==================== Hook Helpers ====================
3125
3245
 
3126
- def format_hook_messages(messages, instance_name):
3246
+ def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
3127
3247
  """Format messages for hook feedback"""
3128
3248
  if len(messages) == 1:
3129
3249
  msg = messages[0]
@@ -3132,100 +3252,42 @@ def format_hook_messages(messages, instance_name):
3132
3252
  parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
3133
3253
  reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
3134
3254
 
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}]"
3255
+ # Only append hints to messages
3256
+ hints = get_config().hints
3257
+ if hints:
3258
+ reason = f"{reason} | [{hints}]"
3139
3259
 
3140
3260
  return reason
3141
3261
 
3142
3262
  # ==================== Hook Handlers ====================
3143
3263
 
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:
3264
+ def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -> tuple[str, dict[str, Any], bool]:
3150
3265
  """
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:
3266
+ Initialize instance context. Flow:
3194
3267
  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
3268
+ 2. Create instance file if fresh start in UserPromptSubmit
3269
+ 3. Build updates dict
3270
+ 4. Return (instance_name, updates, is_matched_resume)
3198
3271
  """
3199
3272
  session_id = hook_data.get('session_id', '')
3200
3273
  transcript_path = hook_data.get('transcript_path', '')
3201
- source = hook_data.get('source', 'startup')
3202
- prefix = os.environ.get('HCOM_PREFIX')
3274
+ tag = get_config().tag
3203
3275
 
3204
- # Step 1: Resolve instance name
3205
- instance_name, existing_data = resolve_instance_name(session_id, prefix)
3276
+ # Resolve instance name - existing_data is None for fresh starts
3277
+ instance_name, existing_data = resolve_instance_name(session_id, tag)
3206
3278
 
3207
3279
  # Save migrated data if we have it
3208
3280
  if existing_data:
3209
3281
  save_instance_position(instance_name, existing_data)
3210
3282
 
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:
3283
+ # Create instance file if fresh start in UserPromptSubmit
3284
+ if existing_data is None and hook_type == 'userpromptsubmit':
3224
3285
  initialize_instance_in_position_file(instance_name, session_id)
3225
3286
 
3226
- # Step 4: Build updates dict
3287
+ # Build updates dict
3227
3288
  updates: dict[str, Any] = {
3228
3289
  'directory': str(Path.cwd()),
3290
+ 'tag': tag,
3229
3291
  }
3230
3292
 
3231
3293
  if session_id:
@@ -3239,11 +3301,10 @@ def init_hook_context(hook_data, hook_type=None):
3239
3301
  updates['background'] = True
3240
3302
  updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
3241
3303
 
3242
- # Return compatible with existing callers
3243
- is_resume_match = (scenario == SessionScenario.MATCHED_RESUME)
3244
-
3304
+ # Simple boolean: matched resume if existing_data found
3305
+ is_matched_resume = (existing_data is not None)
3245
3306
 
3246
- return instance_name, updates, is_resume_match, is_new_instance
3307
+ return instance_name, updates, is_matched_resume
3247
3308
 
3248
3309
  def pretooluse_decision(decision: str, reason: str) -> None:
3249
3310
  """Exit PreToolUse hook with permission decision"""
@@ -3257,7 +3318,7 @@ def pretooluse_decision(decision: str, reason: str) -> None:
3257
3318
  print(json.dumps(output, ensure_ascii=False))
3258
3319
  sys.exit(EXIT_SUCCESS)
3259
3320
 
3260
- def handle_pretooluse(hook_data, instance_name, updates):
3321
+ def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3261
3322
  """Handle PreToolUse hook - check force_closed, inject session_id"""
3262
3323
  instance_data = load_instance_position(instance_name)
3263
3324
  tool_name = hook_data.get('tool_name', '')
@@ -3271,18 +3332,16 @@ def handle_pretooluse(hook_data, instance_name, updates):
3271
3332
  if instance_data.get('enabled', False):
3272
3333
  set_status(instance_name, 'tool_pending', tool_name)
3273
3334
 
3274
- # Inject session_id into hcom send/stop/start commands via updatedInput
3335
+ # Inject session_id into hcom commands via updatedInput
3275
3336
  if tool_name == 'Bash' and session_id:
3276
3337
  command = hook_data.get('tool_input', {}).get('command', '')
3277
3338
 
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)
3339
+ # Match hcom commands for session_id injection and auto-approval
3340
+ matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
3341
+ if matches:
3342
+ # Inject all if chained (&&, ||, ;, |), otherwise first only (avoids quoted text in messages)
3343
+ inject_all = len(matches) > 1 and any(op in command[matches[0].end():matches[1].start()] for op in ['&&', '||', ';', '|'])
3344
+ modified_command = HCOM_COMMAND_PATTERN.sub(rf'\g<0> --_hcom_session {session_id}', command, count=0 if inject_all else 1)
3286
3345
 
3287
3346
  output = {
3288
3347
  "hookSpecificOutput": {
@@ -3298,13 +3357,12 @@ def handle_pretooluse(hook_data, instance_name, updates):
3298
3357
 
3299
3358
 
3300
3359
 
3301
- def handle_stop(hook_data, instance_name, updates):
3360
+ def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3302
3361
  """Handle Stop hook - poll for messages and deliver"""
3303
3362
 
3304
3363
  try:
3305
- entry_time = time.time()
3306
- updates['last_stop'] = entry_time
3307
- timeout = get_config_value('wait_timeout', 1800)
3364
+ updates['last_stop'] = time.time()
3365
+ timeout = get_config().timeout
3308
3366
  updates['wait_timeout'] = timeout
3309
3367
  set_status(instance_name, 'waiting')
3310
3368
 
@@ -3316,39 +3374,57 @@ def handle_stop(hook_data, instance_name, updates):
3316
3374
  start_time = time.time()
3317
3375
 
3318
3376
  try:
3319
- loop_count = 0
3377
+ first_poll = True
3320
3378
  last_heartbeat = start_time
3321
3379
  # Actual polling loop - this IS the holding pattern
3322
3380
  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
3381
+ if first_poll:
3382
+ first_poll = False
3326
3383
 
3327
- # Load instance data once per poll
3384
+ # Reload instance data each poll iteration
3328
3385
  instance_data = load_instance_position(instance_name)
3329
3386
 
3387
+ # Check flag file FIRST (highest priority coordination signal)
3388
+ flag_file = get_user_input_flag_file(instance_name)
3389
+ if flag_file.exists():
3390
+ try:
3391
+ flag_file.unlink()
3392
+ except (FileNotFoundError, PermissionError):
3393
+ # Already deleted or locked, continue anyway
3394
+ pass
3395
+ sys.exit(EXIT_SUCCESS)
3396
+
3330
3397
  # Check if session ended (SessionEnd hook fired) - exit without changing status
3331
3398
  if instance_data.get('session_ended'):
3332
3399
  sys.exit(EXIT_SUCCESS) # Don't overwrite session_ended status
3333
3400
 
3334
- # Check if user input is pending - exit cleanly if recent input
3401
+ # Check if user input is pending (timestamp fallback) - exit cleanly if recent input
3335
3402
  last_user_input = instance_data.get('last_user_input', 0)
3336
3403
  if time.time() - last_user_input < 0.2:
3337
3404
  sys.exit(EXIT_SUCCESS) # Don't overwrite status - let current status remain
3338
3405
 
3339
- # Check if closed - exit cleanly
3406
+ # Check if stopped/disabled - exit cleanly
3340
3407
  if not instance_data.get('enabled', False):
3341
3408
  sys.exit(EXIT_SUCCESS) # Preserve 'stopped' status set by cmd_stop
3342
3409
 
3343
3410
  # Check for new messages and deliver
3344
3411
  if messages := get_unread_messages(instance_name, update_position=True):
3345
- messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
3412
+ messages_to_show = messages[:MAX_MESSAGES_PER_DELIVERY]
3346
3413
  reason = format_hook_messages(messages_to_show, instance_name)
3347
3414
  set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
3348
3415
 
3349
3416
  output = {"decision": "block", "reason": reason}
3350
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
3351
- sys.exit(EXIT_BLOCK)
3417
+ output_json = json.dumps(output, ensure_ascii=False)
3418
+
3419
+ # Log what we're about to output for debugging
3420
+ log_hook_error(f'stop:delivering_message|output_len={len(output_json)}', None)
3421
+ log_hook_error(f'stop:output_json|{output_json}', None)
3422
+
3423
+ # Use JSON output method: stdout + exit 0 (per Claude Code hooks reference)
3424
+ # The "decision": "block" field prevents stoppage, allowing next poll cycle
3425
+ print(output_json)
3426
+ sys.stdout.flush()
3427
+ sys.exit(EXIT_SUCCESS)
3352
3428
 
3353
3429
  # Update heartbeat every 0.5 seconds for staleness detection
3354
3430
  now = time.time()
@@ -3373,45 +3449,64 @@ def handle_stop(hook_data, instance_name, updates):
3373
3449
  log_hook_error('handle_stop', e)
3374
3450
  sys.exit(EXIT_SUCCESS) # Preserve previous status on exception
3375
3451
 
3376
- def handle_notify(hook_data, instance_name, updates):
3452
+ def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3377
3453
  """Handle Notification hook - track permission requests"""
3378
3454
  updates['notification_message'] = hook_data.get('message', '')
3379
3455
  update_instance_position(instance_name, updates)
3380
3456
  set_status(instance_name, 'blocked', hook_data.get('message', ''))
3381
3457
 
3382
- def wait_for_stop_exit(instance_name, max_wait=0.2):
3383
- """Wait for Stop hook to exit. Returns wait time in ms."""
3384
- start = time.time()
3458
+ def get_user_input_flag_file(instance_name: str) -> Path:
3459
+ """Get path to user input coordination flag file"""
3460
+ return hcom_path(INSTANCES_DIR, f'{instance_name}.user_input')
3385
3461
 
3386
- while time.time() - start < max_wait:
3387
- time.sleep(0.01)
3462
+ def wait_for_stop_exit(instance_name: str, max_wait: float = 0.2) -> int:
3463
+ """
3464
+ Wait for Stop hook to exit using flag file coordination.
3465
+ Returns wait time in ms.
3388
3466
 
3389
- data = load_instance_position(instance_name)
3390
- last_stop_age = time.time() - data.get('last_stop', 0)
3467
+ Strategy:
3468
+ 1. Create flag file
3469
+ 2. Wait for Stop hook to delete it (proof it exited)
3470
+ 3. Fallback to timeout if Stop hook doesn't delete flag
3471
+ """
3472
+ start = time.time()
3473
+ flag_file = get_user_input_flag_file(instance_name)
3391
3474
 
3392
- if last_stop_age > 0.2:
3393
- return int((time.time() - start) * 1000)
3475
+ # Wait for flag file to be deleted by Stop hook
3476
+ while flag_file.exists() and time.time() - start < max_wait:
3477
+ time.sleep(0.01)
3394
3478
 
3395
3479
  return int((time.time() - start) * 1000)
3396
3480
 
3397
- def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
3481
+ def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], is_matched_resume: bool, instance_data: dict[str, Any] | None) -> None:
3398
3482
  """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)
3483
+ is_enabled = instance_data.get('enabled', False) if instance_data else False
3484
+ last_stop = instance_data.get('last_stop', 0) if instance_data else 0
3485
+ alias_announced = instance_data.get('alias_announced', False) if instance_data else False
3486
+
3487
+ # Session_ended prevents user recieving messages(?) so reset it.
3488
+ if is_matched_resume and instance_data and instance_data.get('session_ended'):
3489
+ update_instance_position(instance_name, {'session_ended': False})
3490
+ instance_data['session_ended'] = False # Resume path reactivates Stop hook polling
3404
3491
 
3405
3492
  # Coordinate with Stop hook only if enabled AND Stop hook is active
3406
3493
  stop_is_active = (time.time() - last_stop) < 1.0
3407
3494
 
3408
3495
  if is_enabled and stop_is_active:
3496
+ # Create flag file for coordination
3497
+ flag_file = get_user_input_flag_file(instance_name)
3498
+ try:
3499
+ flag_file.touch()
3500
+ except (OSError, PermissionError):
3501
+ # Failed to create flag, fall back to timestamp-only coordination
3502
+ pass
3503
+
3504
+ # Set timestamp (backup mechanism)
3409
3505
  updates['last_user_input'] = time.time()
3410
3506
  update_instance_position(instance_name, updates)
3411
- wait_ms = wait_for_stop_exit(instance_name)
3412
3507
 
3413
- send_cmd = build_send_command('your message', instance_name)
3414
- resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
3508
+ # Wait for Stop hook to delete flag file
3509
+ wait_for_stop_exit(instance_name)
3415
3510
 
3416
3511
  # Build message based on what happened
3417
3512
  msg = None
@@ -3419,18 +3514,8 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
3419
3514
  # Determine if this is an HCOM-launched instance
3420
3515
  is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
3421
3516
 
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:
3517
+ # Show bootstrap if not already announced
3518
+ if not alias_announced:
3434
3519
  if is_hcom_launched:
3435
3520
  # HCOM-launched instance - show bootstrap immediately
3436
3521
  msg = build_hcom_bootstrap_text(instance_name)
@@ -3445,8 +3530,8 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
3445
3530
  msg += build_hcom_bootstrap_text(instance_name)
3446
3531
  update_instance_position(instance_name, {'alias_announced': True})
3447
3532
 
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:
3533
+ # Add resume status note if we showed bootstrap for a matched resume
3534
+ if msg and is_matched_resume:
3450
3535
  if is_enabled:
3451
3536
  msg += "\n[Session resumed. HCOM started for this instance - will receive chat messages. Your alias and conversation history preserved.]"
3452
3537
  else:
@@ -3454,34 +3539,48 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
3454
3539
 
3455
3540
  if msg:
3456
3541
  output = {
3457
- # "systemMessage": "HCOM enabled",
3458
3542
  "hookSpecificOutput": {
3459
3543
  "hookEventName": "UserPromptSubmit",
3460
3544
  "additionalContext": msg
3461
3545
  }
3462
3546
  }
3463
3547
  print(json.dumps(output), file=sys.stdout)
3464
- # sys.exit(1)
3465
3548
 
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')
3549
+ def handle_sessionstart(hook_data: dict[str, Any]) -> None:
3550
+ """Handle SessionStart hook - initial msg & reads environment variables"""
3551
+ # Only show message for HCOM-launched instances
3552
+ if os.environ.get('HCOM_LAUNCHED') != '1':
3553
+ return
3469
3554
 
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')
3555
+ # Build minimal context from environment
3556
+ parts = ["[HCOM active]"]
3557
+
3558
+ if agent_type := os.environ.get('HCOM_SUBAGENT_TYPE'):
3559
+ parts.append(f"[agent: {agent_type}]")
3560
+
3561
+ if tag := os.environ.get('HCOM_TAG'):
3562
+ parts.append(f"[tag: {tag}]")
3563
+
3564
+ help_text = " ".join(parts)
3565
+
3566
+ # First time: no instance files or archives exist
3567
+ is_first_time = not any(hcom_path().rglob('*.json'))
3568
+
3569
+ is_first_time = True
3570
+ if is_first_time:
3571
+ help_text += """
3476
3572
 
3477
- # Minimal message - no alias yet (UserPromptSubmit will show full details)
3478
- help_text = "[HCOM active. Submit a prompt to initialize.]"
3573
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3574
+ Welcome to Hook Comms!
3575
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3576
+ Dashboard: hcom watch
3577
+ Toggle on/off: hcom stop / hcom start
3578
+ Launch: hcom 3
3579
+ All commands: hcom help
3580
+ Config: ~/.hcom/config.env
3581
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3479
3582
 
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}]"
3583
+ """
3485
3584
 
3486
3585
  output = {
3487
3586
  "hookSpecificOutput": {
@@ -3492,7 +3591,7 @@ def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
3492
3591
 
3493
3592
  print(json.dumps(output))
3494
3593
 
3495
- def handle_sessionend(hook_data, instance_name, updates):
3594
+ def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3496
3595
  """Handle SessionEnd hook - mark session as ended and set final status"""
3497
3596
  reason = hook_data.get('reason', 'unknown')
3498
3597
 
@@ -3507,6 +3606,35 @@ def handle_sessionend(hook_data, instance_name, updates):
3507
3606
  except Exception as e:
3508
3607
  log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
3509
3608
 
3609
+ def should_skip_vanilla_instance(hook_type: str, hook_data: dict) -> bool:
3610
+ """
3611
+ Returns True if hook should exit early.
3612
+ Vanilla instances (not HCOM-launched) exit early unless:
3613
+ - Enabled
3614
+ - PreToolUse (handles opt-in)
3615
+ - UserPromptSubmit with hcom command in prompt (shows preemptive bootstrap)
3616
+ """
3617
+ # PreToolUse always runs (handles toggle commands)
3618
+ # HCOM-launched instances always run
3619
+ if hook_type == 'pre' or os.environ.get('HCOM_LAUNCHED') == '1':
3620
+ return False
3621
+
3622
+ session_id = hook_data.get('session_id', '')
3623
+ if not session_id: # No session_id = can't identify instance, skip hook
3624
+ return True
3625
+
3626
+ instance_name = get_display_name(session_id, get_config().tag)
3627
+ instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3628
+
3629
+ if not instance_file.exists():
3630
+ # Allow UserPromptSubmit if prompt contains hcom command
3631
+ if hook_type == 'userpromptsubmit':
3632
+ user_prompt = hook_data.get('prompt', '')
3633
+ return not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE)
3634
+ return True
3635
+
3636
+ return False
3637
+
3510
3638
  def handle_hook(hook_type: str) -> None:
3511
3639
  """Unified hook handler for all HCOM hooks"""
3512
3640
  hook_data = json.load(sys.stdin)
@@ -3515,130 +3643,99 @@ def handle_hook(hook_type: str) -> None:
3515
3643
  log_hook_error('handle_hook', Exception('Failed to create directories'))
3516
3644
  sys.exit(EXIT_SUCCESS)
3517
3645
 
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')
3646
+ # SessionStart is standalone - no instance files
3647
+ if hook_type == 'sessionstart':
3648
+ handle_sessionstart(hook_data)
3649
+ sys.exit(EXIT_SUCCESS)
3532
3650
 
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)
3651
+ # Vanilla instance check - exit early if should skip
3652
+ if should_skip_vanilla_instance(hook_type, hook_data):
3653
+ sys.exit(EXIT_SUCCESS)
3542
3654
 
3543
3655
  # 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)
3545
-
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)
3656
+ instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
3551
3657
 
3552
- # Check enabled status (PreToolUse handles toggle, so exempt)
3658
+ # Load instance data once (for enabled check and to pass to handlers)
3659
+ instance_data = None
3553
3660
  if hook_type != 'pre':
3554
3661
  instance_data = load_instance_position(instance_name)
3555
- if not instance_data.get('enabled', False):
3662
+
3663
+ # Skip enabled check for UserPromptSubmit when bootstrap needs to be shown
3664
+ # (alias_announced=false means bootstrap hasn't been shown yet)
3665
+ skip_enabled_check = (hook_type == 'userpromptsubmit' and
3666
+ not instance_data.get('alias_announced', False))
3667
+
3668
+ if not skip_enabled_check and not instance_data.get('enabled', False):
3556
3669
  sys.exit(EXIT_SUCCESS)
3557
3670
 
3558
3671
  match hook_type:
3559
3672
  case 'pre':
3560
- handle_pretooluse(hook_data, instance_name, updates)
3673
+ handle_pretooluse(hook_data, instance_name)
3561
3674
  case 'poll':
3562
- handle_stop(hook_data, instance_name, updates)
3675
+ handle_stop(hook_data, instance_name, updates, instance_data)
3563
3676
  case 'notify':
3564
- handle_notify(hook_data, instance_name, updates)
3677
+ handle_notify(hook_data, instance_name, updates, instance_data)
3565
3678
  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)
3679
+ handle_userpromptsubmit(hook_data, instance_name, updates, is_matched_resume, instance_data)
3569
3680
  case 'sessionend':
3570
- handle_sessionend(hook_data, instance_name, updates)
3681
+ handle_sessionend(hook_data, instance_name, updates, instance_data)
3571
3682
 
3572
3683
  sys.exit(EXIT_SUCCESS)
3573
3684
 
3574
3685
 
3575
3686
  # ==================== Main Entry Point ====================
3576
3687
 
3577
- def main(argv=None):
3688
+ def main(argv: list[str] | None = None) -> int | None:
3578
3689
  """Main command dispatcher"""
3579
3690
  if argv is None:
3580
3691
  argv = sys.argv[1:]
3581
3692
  else:
3582
3693
  argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
3583
3694
 
3584
- # Check for help
3585
- if needs_help(argv):
3586
- return cmd_help()
3587
-
3588
- # Handle hook commands (special case - no parsing needed)
3695
+ # Hook handlers only (called BY hooks, not users)
3589
3696
  if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
3590
3697
  handle_hook(argv[0])
3591
3698
  return 0
3592
3699
 
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)
3700
+ # Check for updates (CLI commands only, not hooks)
3701
+ check_version_once_daily()
3602
3702
 
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
3608
-
3609
- # Build parser and parse arguments
3610
- parser = build_parser()
3703
+ # Ensure directories exist
3704
+ if not ensure_hcom_directories():
3705
+ print(format_error("Failed to create HCOM directories"), file=sys.stderr)
3706
+ return 1
3611
3707
 
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
3708
+ # Ensure hooks current (warns but never blocks)
3709
+ ensure_hooks_current()
3618
3710
 
3619
- # Dispatch to command parsers and get typed command objects
3711
+ # Route to commands
3620
3712
  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)
3713
+ if not argv or argv[0] in ('help', '--help', '-h'):
3714
+ return cmd_help()
3715
+ elif argv[0] == 'send_cli':
3716
+ if len(argv) < 2:
3717
+ print(format_error("Message required"), file=sys.stderr)
3718
+ return 1
3719
+ return send_cli(argv[1])
3720
+ elif argv[0] == 'watch':
3721
+ return cmd_watch(argv[1:])
3722
+ elif argv[0] == 'send':
3723
+ return cmd_send(argv[1:])
3724
+ elif argv[0] == 'stop':
3725
+ return cmd_stop(argv[1:])
3726
+ elif argv[0] == 'start':
3727
+ return cmd_start(argv[1:])
3728
+ elif argv[0] == 'reset':
3729
+ return cmd_reset(argv[1:])
3730
+ elif argv[0].isdigit() or argv[0] == 'claude':
3731
+ # Launch instances: hcom <1-100> [args] or hcom claude [args]
3732
+ return cmd_launch(argv)
3638
3733
  else:
3639
- print(format_error(f"Unknown command type: {type(command_obj)}"), file=sys.stderr)
3734
+ print(format_error(
3735
+ f"Unknown command: {argv[0]}",
3736
+ "Run 'hcom --help' for usage"
3737
+ ), file=sys.stderr)
3640
3738
  return 1
3641
-
3642
3739
  except CLIError as exc:
3643
3740
  print(str(exc), file=sys.stderr)
3644
3741
  return 1