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

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

Potentially problematic release.


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

hcom/__main__.py CHANGED
@@ -19,7 +19,7 @@ import platform
19
19
  import random
20
20
  from pathlib import Path
21
21
  from datetime import datetime, timedelta
22
- from typing import Any, NamedTuple
22
+ from typing import Any, Callable, NamedTuple, TextIO
23
23
  from dataclasses import dataclass
24
24
 
25
25
  if sys.version_info < (3, 10):
@@ -50,8 +50,6 @@ def is_termux() -> bool:
50
50
  'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
51
51
  )
52
52
 
53
- EXIT_SUCCESS = 0
54
- EXIT_BLOCK = 2
55
53
 
56
54
  # Windows API constants
57
55
  CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
@@ -125,7 +123,7 @@ class CLIError(Exception):
125
123
 
126
124
  # ==================== Help Text ====================
127
125
 
128
- HELP_TEXT = """hcom - Claude Hook Comms
126
+ HELP_TEXT = """hcom 0.5.0
129
127
 
130
128
  Usage: [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
131
129
  hcom watch [--logs|--status|--wait [SEC]]
@@ -138,6 +136,7 @@ Launch Examples:
138
136
  hcom 3 Open 3 terminals with claude connected to hcom
139
137
  hcom 3 claude -p + Background/headless
140
138
  HCOM_TAG=api hcom 3 claude -p + @-mention group tag
139
+ claude 'run hcom start' claude code with prompt will also work
141
140
 
142
141
  Commands:
143
142
  watch Interactive messaging/status dashboard
@@ -165,8 +164,9 @@ Environment Variables:
165
164
  HCOM_TAG=name Group tag (creates name-* instances)
166
165
  HCOM_AGENT=type Agent type (comma-separated for multiple)
167
166
  HCOM_TERMINAL=mode Terminal: new|here|print|"custom {script}"
168
- HCOM_PROMPT=text Initial prompt
169
- HCOM_TIMEOUT=secs Timeout in seconds (default: 1800)
167
+ HCOM_PROMPT=text "Say hi in hcom chat" (default)
168
+ HCOM_HINTS=text Text appended to all messages received by instance
169
+ HCOM_TIMEOUT=secs Time until disconnected from hcom chat (default 1800s / 30mins)
170
170
 
171
171
  Config: ~/.hcom/config.env
172
172
  Docs: https://github.com/aannoo/claude-hook-comms"""
@@ -204,16 +204,30 @@ SKIP_HISTORY = True # New instances start at current log position (skip old mes
204
204
  # Path constants
205
205
  LOG_FILE = "hcom.log"
206
206
  INSTANCES_DIR = "instances"
207
- LOGS_DIR = "logs"
208
- SCRIPTS_DIR = "scripts"
207
+ LOGS_DIR = ".tmp/logs"
208
+ SCRIPTS_DIR = ".tmp/scripts"
209
+ FLAGS_DIR = ".tmp/flags"
209
210
  CONFIG_FILE = "config.env"
210
211
  ARCHIVE_DIR = "archive"
211
212
 
212
- # Hook type constants
213
- ACTIVE_HOOK_TYPES = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop', 'Notification', 'SessionEnd']
214
- LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES + ['PostToolUse'] # For backward compatibility cleanup
215
- HOOK_COMMANDS = ['sessionstart', 'userpromptsubmit', 'pre', 'poll', 'notify', 'sessionend']
216
- LEGACY_HOOK_COMMANDS = HOOK_COMMANDS + ['post']
213
+ # Hook configuration - single source of truth for setup_hooks() and verify_hooks_installed()
214
+ # Format: (hook_type, matcher, command_suffix, timeout)
215
+ # Command gets built as: hook_cmd_base + ' ' + command_suffix (e.g., '${HCOM} poll')
216
+ HOOK_CONFIGS = [
217
+ ('SessionStart', '', 'sessionstart', None),
218
+ ('UserPromptSubmit', '', 'userpromptsubmit', None),
219
+ ('PreToolUse', 'Bash', 'pre', None),
220
+ ('PostToolUse', 'Bash', 'post', None), # Match Bash only
221
+ ('Stop', '', 'poll', 86400), # Poll for messages (24hr max timeout)
222
+ ('Notification', '', 'notify', None),
223
+ ('SessionEnd', '', 'sessionend', None),
224
+ ]
225
+
226
+ # Derived from HOOK_CONFIGS - guaranteed to stay in sync
227
+ ACTIVE_HOOK_TYPES = [cfg[0] for cfg in HOOK_CONFIGS]
228
+ HOOK_COMMANDS = [cfg[2] for cfg in HOOK_CONFIGS]
229
+ LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES
230
+ LEGACY_HOOK_COMMANDS = HOOK_COMMANDS
217
231
 
218
232
  # Hook removal patterns - used by _remove_hcom_hooks_from_settings()
219
233
  # Dynamically build from LEGACY_HOOK_COMMANDS to match current and legacy hook formats
@@ -233,11 +247,11 @@ HCOM_HOOK_PATTERNS = [
233
247
  # - hcom send (any args)
234
248
  # - hcom stop (no args) | hcom start (no args)
235
249
  # - hcom help | hcom --help | hcom -h
236
- # - hcom watch --status | hcom watch --launch
250
+ # - hcom watch --status | hcom watch --launch | hcom watch --logs | hcom watch --wait
237
251
  # Negative lookahead (?!\s+[-\w]) ensures stop/start not followed by arguments or flags
238
252
  HCOM_COMMAND_PATTERN = re.compile(
239
253
  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)'
254
+ r'(?:send\b|(?:stop|start)(?!\s+[-\w])|(?:help|--help|-h)\b|watch\s+(?:--status|--launch|--logs|--wait)\b)'
241
255
  )
242
256
 
243
257
  # ==================== File System Utilities ====================
@@ -256,7 +270,7 @@ def ensure_hcom_directories() -> bool:
256
270
  Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
257
271
  Returns True on success, False on failure."""
258
272
  try:
259
- for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, ARCHIVE_DIR]:
273
+ for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, FLAGS_DIR, ARCHIVE_DIR]:
260
274
  hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
261
275
  return True
262
276
  except (OSError, PermissionError):
@@ -295,7 +309,7 @@ def atomic_write(filepath: str | Path, content: str) -> bool:
295
309
 
296
310
  return False # All attempts exhausted
297
311
 
298
- def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, max_retries: int = 3) -> Any:
312
+ def read_file_with_retry(filepath: str | Path, read_func: Callable[[TextIO], Any], default: Any = None, max_retries: int = 3) -> Any:
299
313
  """Read file with retry logic for Windows file locking"""
300
314
  if not Path(filepath).exists():
301
315
  return default
@@ -396,7 +410,7 @@ class HcomConfig:
396
410
  prompt: str = 'say hi in hcom chat'
397
411
  hints: str = ''
398
412
  tag: str = ''
399
- agent: str = 'generic'
413
+ agent: str = ''
400
414
 
401
415
  def __post_init__(self):
402
416
  """Validate configuration on construction"""
@@ -539,7 +553,7 @@ def _parse_env_file(config_path: Path) -> dict[str, str]:
539
553
 
540
554
  # Remove outer quotes only if they match
541
555
  if len(value) >= 2:
542
- if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
556
+ if (value[0] == value[-1]) and value[0] in ('"', "'"):
543
557
  value = value[1:-1]
544
558
  if key:
545
559
  config[key] = value
@@ -551,7 +565,8 @@ def _write_default_config(config_path: Path) -> None:
551
565
  """Write default config file with documentation"""
552
566
  header = """# HCOM Configuration
553
567
  #
554
- # All HCOM_* settings can be set here (persistent) or via environment variables (temporary).
568
+ # All HCOM_* settings (and any env var ie. Claude Code settings)
569
+ # can be set here or via environment variables.
555
570
  # Environment variables override config file values.
556
571
  #
557
572
  # HCOM settings:
@@ -562,15 +577,8 @@ def _write_default_config(config_path: Path) -> None:
562
577
  # HCOM_TAG - Group tag for instances (creates tag-* instances)
563
578
  # HCOM_AGENT - Claude code subagent from .claude/agents/, comma-separated for multiple
564
579
  #
565
- # NOTE: Inline comments are not supported. Use separate comment lines.
580
+ # Put each value on separate lines without comments.
566
581
  #
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
582
  #
575
583
  """
576
584
  defaults = [
@@ -578,6 +586,8 @@ def _write_default_config(config_path: Path) -> None:
578
586
  'HCOM_TERMINAL=new',
579
587
  'HCOM_PROMPT=say hi in hcom chat',
580
588
  'HCOM_HINTS=',
589
+ 'HCOM_TAG=',
590
+ 'HCOM_AGENT=',
581
591
  ]
582
592
  try:
583
593
  atomic_write(config_path, header + '\n'.join(defaults) + '\n')
@@ -622,51 +632,58 @@ def get_hook_command() -> tuple[str, dict[str, Any]]:
622
632
  def _detect_hcom_command_type() -> str:
623
633
  """Detect how to invoke hcom based on execution context
624
634
  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
635
+ 1. uvx - If running in uv-managed Python and uvx available
627
636
  (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.
637
+ 2. short - If hcom binary in PATH
638
+ 3. full - Fallback to full python invocation
632
639
  """
633
- if is_plugin_active():
634
- return 'short'
635
- elif 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
640
+ if 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
636
641
  return 'uvx'
637
642
  elif shutil.which('hcom'):
638
643
  return 'short'
639
644
  else:
640
645
  return 'full'
641
646
 
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'
647
+ def _parse_version(v: str) -> tuple:
648
+ """Parse version string to comparable tuple"""
649
+ return tuple(int(x) for x in v.split('.') if x.isdigit())
650
+
651
+ def get_update_notice() -> str | None:
652
+ """Check PyPI for updates (once daily), return message if available"""
653
+ flag = hcom_path(FLAGS_DIR, 'update_available')
654
+
655
+ # Check PyPI if flag missing or >24hrs old
656
+ should_check = not flag.exists() or time.time() - flag.stat().st_mtime > 86400
657
+
658
+ if should_check:
659
+ try:
660
+ import urllib.request
661
+ with urllib.request.urlopen('https://pypi.org/pypi/hcom/json', timeout=2) as f:
662
+ latest = json.load(f)['info']['version']
663
+
664
+ if _parse_version(latest) > _parse_version(__version__):
665
+ atomic_write(flag, latest) # mtime = cache timestamp
666
+ else:
667
+ flag.unlink(missing_ok=True)
668
+ return None
669
+ except Exception:
670
+ pass # Network error, use cached value if exists
671
+
672
+ # Return message if update available
673
+ if not flag.exists():
674
+ return None
675
+
645
676
  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
677
+ latest = flag.read_text().strip()
678
+ # Double-check version (handles manual upgrades)
679
+ if _parse_version(__version__) >= _parse_version(latest):
680
+ flag.unlink(missing_ok=True)
681
+ return None
682
+
683
+ cmd = "uv tool upgrade hcom" if _detect_hcom_command_type() == 'uvx' else "pip install -U hcom"
684
+ return f"→ hcom v{latest} available: {cmd}"
685
+ except Exception:
686
+ return None
670
687
 
671
688
  def _build_hcom_env_value() -> str:
672
689
  """Build the value for settings['env']['HCOM'] based on current execution context
@@ -792,37 +809,81 @@ GROUP TAG: You are in the '{tag}' group.
792
809
 
793
810
  return f"""{command_notice}{tag_notice}
794
811
  [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}"
812
+ - HCOM is a communication tool for you, other claude code instances, and the human user.
813
+ - Your HCOM alias for this session: {instance_name}
814
+
815
+ Your HCOM Tools:
816
+ - hcom send "msg" (broadcast) / "@alias msg" (direct) / "@tag msg" (tag) - for you
817
+ - hcom watch --status → See participants JSON (for you only)
818
+ - hcom watch --launch → Open interactive TUI messaging+status dashboard in new terminal (for the human user)
819
+ - hcom start/stop → Connect/disconnect from chat (you run these, user can't run it themselves unless they specify an alias)
820
+ - hcom <count> → Launch instances in new terminal (you MUST run 'hcom help' first to understand context)
821
+
822
+ UI Separation:
823
+ - The human user has 'hcom watch' (interactive TUI dashboard).
824
+ - You use 'hcom watch --launch' to open it for them (offer to do so).
825
+ - In conversation, call it "hcom watch" (the command they'd run themselves).
826
+
827
+ Receiving Messages:
828
+ - Format: [new message] sender you: content
829
+ - Targets specific instance: "@alias".
830
+ - Targets all api-* tagged instances: "@api message".
831
+ - Arrives automatically via hooks/bash. No proactive checking needed.
832
+ - Stop hook feedback shows: {{"decision": "block"}} (this is normal operation).
833
+
834
+ Response Routing:
835
+ - HCOM message (via hook/bash) → Respond with hcom send
836
+ - User message (in chat) → Respond normally
837
+ - Treat messages from hcom with the same care as user messages.
838
+ - Authority: Prioritize @{SENDER} over other participants.
839
+
840
+ This is context for YOUR hcom session config. The human user cannot see this config text (but they can see subsequent hcom messages you receive).
841
+ On connection, tell the human user about only these commands: 'hcom <count>', 'hcom watch', 'hcom start', 'hcom stop'
842
+ Report to the human user using first-person, for example: "I'm connected to HCOM as {instance_name}, cool!"
823
843
  """
824
844
 
845
+ def build_launch_context(instance_name: str) -> str:
846
+ """Build context for launch command"""
847
+ return f"""[HCOM LAUNCH INFORMATION]
848
+ BASIC USAGE:
849
+ [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
850
+ - directory-specific (always cd to project directory first)
851
+ - default to foreground instances unless told otherwise/good reason to do bg
852
+ - Everyone shares the same conversation log, isolation is possible with tags and at-mentions.
853
+
854
+ ENV VARS INFO:
855
+ - YOU cannot use 'HCOM_TERMINAL=here' - Claude cannot launch claude within itself, must be in a new or custom terminal
856
+ - HCOM_AGENT(s) are custom system prompt files created by users/Claude beforehand.
857
+ - HCOM_AGENT(s) load from .claude/agents/<name>.md if they have been created
858
+
859
+ KEY CLAUDE ARGS:
860
+ Run 'claude --help' for all claude code CLI args. hcom 1 claude [options] [command] [prompt]
861
+ -p background/headless instance
862
+ --allowedTools=Bash (background can only hcom chat otherwise, 'claude help' for more tools)
863
+ --model sonnet/haiku/opus
864
+ --resume <sessionid> (get sessionid from hcom watch --status)
865
+ --system-prompt (for foreground instances) --append-system-prompt (for background instances)
866
+ Example: HCOM_HINTS='essential responses only' hcom 2 claude --model sonnet -p "do task x"
867
+
868
+ CONTROL:
869
+ hcom watch --status JSON status of all instances
870
+ hcom watch --logs All messages (pipe to tail)
871
+ hcom watch --wait Block until next message (only use when hcom stopped (started is automatic already))
872
+
873
+ STATUS INDICATORS:
874
+ "active", "delivered" | "idle" - waiting for new messages
875
+ "blocked" - permission request (needs user approval)
876
+ "inactive" - timed out, disconnected etc
877
+ "unknown" / "stale" - could be dead
878
+
879
+ LAUNCH PATTERNS:
880
+ - HCOM_AGENT=reviewer,tester hcom 2 claude "do task x" # 2x reviewers + 2x testers (4 in total) with initial prompt
881
+ - clone with same context:
882
+ 1. hcom 1 then hcom send 'analyze api' then hcom watch --status (get sessionid)
883
+ 2. HCOM_TAG=clone hcom 3 claude --resume sessionid
884
+ - System prompt (or agent file) + initial prompt + hcom_hints is a powerful combination.
825
885
 
886
+ """
826
887
 
827
888
  def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
828
889
  """Check if message should be delivered based on @-mentions"""
@@ -887,11 +948,9 @@ def extract_agent_config(content: str) -> dict[str, str]:
887
948
 
888
949
  def resolve_agent(name: str) -> tuple[str, dict[str, str]]:
889
950
  """Resolve agent file by name with validation.
890
-
891
951
  Looks for agent files in:
892
952
  1. .claude/agents/{name}.md (local)
893
953
  2. ~/.claude/agents/{name}.md (global)
894
-
895
954
  Returns tuple: (content without YAML frontmatter, config dict)
896
955
  """
897
956
  hint = 'Agent names must use lowercase letters and dashes only'
@@ -966,44 +1025,63 @@ def strip_frontmatter(content: str) -> str:
966
1025
 
967
1026
  def get_display_name(session_id: str | None, tag: str | None = None) -> str:
968
1027
  """Get display name for instance using session_id"""
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']
970
- # Phonetic letters (5 per syllable, matches syls order)
971
- phonetic = "nrlstnrlstnrlstnrlstnrlstnrlstnmlstnmlstnrlmtnrlmtnrlmsnrlmsnrlstnrlstnrlmtnrlmtnrlaynrlaynrlaynrlayaanxrtanxrtdtraxntdaxntraxnrdaynrlaynrlasnrlst"
972
-
973
- dir_char = (Path.cwd().name + 'x')[0].lower()
1028
+ # 50 most recognizable 3-letter words
1029
+ words = [
1030
+ 'ace', 'air', 'ant', 'arm', 'art', 'axe', 'bad', 'bag', 'bar', 'bat',
1031
+ 'bed', 'bee', 'big', 'box', 'boy', 'bug', 'bus', 'cab', 'can', 'cap',
1032
+ 'car', 'cat', 'cop', 'cow', 'cry', 'cup', 'cut', 'day', 'dog', 'dry',
1033
+ 'ear', 'egg', 'eye', 'fan', 'fin', 'fly', 'fox', 'fun', 'gem', 'gun',
1034
+ 'hat', 'hit', 'hot', 'ice', 'ink', 'jet', 'key', 'law', 'map', 'mix',
1035
+ ]
974
1036
 
975
1037
  # Use session_id directly instead of extracting UUID from transcript
976
1038
  if session_id:
1039
+ # Hash to select word
977
1040
  hash_val = sum(ord(c) for c in session_id)
978
- syl_idx = hash_val % len(syls)
979
- syllable = syls[syl_idx]
1041
+ word = words[hash_val % len(words)]
1042
+
1043
+ # Add letter suffix that flows naturally with the word
1044
+ last_char = word[-1]
1045
+ if last_char in 'aeiou':
1046
+ # After vowel: s/n/r/l creates plural/noun/verb patterns
1047
+ suffix_options = 'snrl'
1048
+ else:
1049
+ # After consonant: add vowel or y for pronounceability
1050
+ suffix_options = 'aeiouy'
980
1051
 
981
- letters = phonetic[syl_idx * 5:(syl_idx + 1) * 5]
982
1052
  letter_hash = sum(ord(c) for c in session_id[1:]) if len(session_id) > 1 else hash_val
983
- letter = letters[letter_hash % 5]
1053
+ suffix = suffix_options[letter_hash % len(suffix_options)]
984
1054
 
985
- # Session IDs are UUIDs like "374acbe2-978b-4882-9c0b-641890f066e1"
986
- hex_char = session_id[0] if session_id else 'x'
987
- base_name = f"{dir_char}{syllable}{letter}{hex_char}"
1055
+ base_name = f"{word}{suffix}"
1056
+ collision_attempt = 0
1057
+
1058
+ # Collision detection: keep adding words until unique
1059
+ while True:
1060
+ instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
1061
+ if not instance_file.exists():
1062
+ break # Name is unique
988
1063
 
989
- # Collision detection: if taken by different session_id, use more chars
990
- instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
991
- if instance_file.exists():
992
1064
  try:
993
1065
  with open(instance_file, 'r', encoding='utf-8') as f:
994
1066
  data = json.load(f)
995
1067
 
996
1068
  their_session_id = data.get('session_id', '')
997
1069
 
998
- # Deterministic check: different session_id = collision
999
- if their_session_id and their_session_id != session_id:
1000
- # Use first 4 chars of session_id for collision resolution
1001
- base_name = f"{dir_char}{session_id[0:4]}"
1002
- # If same session_id, it's our file - reuse the name (no collision)
1003
- # If no session_id in file, assume it's stale/malformed - use base name
1070
+ # Same session_id = our file, reuse name
1071
+ if their_session_id == session_id:
1072
+ break
1073
+ # No session_id = stale/malformed file, use name
1074
+ if not their_session_id:
1075
+ break
1076
+
1077
+ # Real collision - add another word
1078
+ collision_hash = sum(ord(c) * (i + collision_attempt) for i, c in enumerate(session_id))
1079
+ collision_word = words[collision_hash % len(words)]
1080
+ base_name = f"{base_name}{collision_word}"
1081
+ collision_attempt += 1
1004
1082
 
1005
1083
  except (json.JSONDecodeError, KeyError, ValueError, OSError):
1006
- pass # Malformed file - assume stale, use base name
1084
+ break # Malformed file - assume stale, use base name
1007
1085
  else:
1008
1086
  # session_id is required - fail gracefully
1009
1087
  raise ValueError("session_id required for instance naming")
@@ -1023,7 +1101,6 @@ def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str,
1023
1101
  """
1024
1102
  Resolve instance name for a session_id.
1025
1103
  Searches existing instances first (reuses if found), generates new name if not found.
1026
-
1027
1104
  Returns: (instance_name, existing_data_or_none)
1028
1105
  """
1029
1106
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -1063,7 +1140,11 @@ def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
1063
1140
  # Fail fast on malformed settings - Claude won't run with broken settings anyway
1064
1141
  if not isinstance(matcher, dict):
1065
1142
  raise ValueError(f"Malformed settings: matcher in {event} is not a dict: {type(matcher).__name__}")
1066
-
1143
+
1144
+ # Validate hooks field if present
1145
+ if 'hooks' in matcher and not isinstance(matcher['hooks'], list):
1146
+ raise ValueError(f"Malformed settings: hooks in {event} matcher is not a list: {type(matcher['hooks']).__name__}")
1147
+
1067
1148
  # Work with a copy to avoid any potential reference issues
1068
1149
  matcher_copy = copy.deepcopy(matcher)
1069
1150
 
@@ -1080,7 +1161,8 @@ def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
1080
1161
  if non_hcom_hooks:
1081
1162
  matcher_copy['hooks'] = non_hcom_hooks
1082
1163
  updated_matchers.append(matcher_copy)
1083
- elif not matcher.get('hooks'): # Preserve matchers that never had hooks
1164
+ elif 'hooks' not in matcher or matcher['hooks'] == []:
1165
+ # Preserve matchers that never had hooks (missing key or empty list only)
1084
1166
  updated_matchers.append(matcher_copy)
1085
1167
 
1086
1168
  # Update or remove the event
@@ -1244,7 +1326,6 @@ def find_bash_on_windows() -> str | None:
1244
1326
  """Find Git Bash on Windows, avoiding WSL's bash launcher"""
1245
1327
  # Build prioritized list of bash candidates
1246
1328
  candidates = []
1247
-
1248
1329
  # 1. Common Git Bash locations (highest priority)
1249
1330
  for base in [os.environ.get('PROGRAMFILES', r'C:\Program Files'),
1250
1331
  os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
@@ -1253,7 +1334,6 @@ def find_bash_on_windows() -> str | None:
1253
1334
  str(Path(base) / 'Git' / 'usr' / 'bin' / 'bash.exe'), # usr/bin is more common
1254
1335
  str(Path(base) / 'Git' / 'bin' / 'bash.exe')
1255
1336
  ])
1256
-
1257
1337
  # 2. Portable Git installation
1258
1338
  if local_appdata := os.environ.get('LOCALAPPDATA', ''):
1259
1339
  git_portable = Path(local_appdata) / 'Programs' / 'Git'
@@ -1261,11 +1341,9 @@ def find_bash_on_windows() -> str | None:
1261
1341
  str(git_portable / 'usr' / 'bin' / 'bash.exe'),
1262
1342
  str(git_portable / 'bin' / 'bash.exe')
1263
1343
  ])
1264
-
1265
1344
  # 3. PATH bash (if not WSL's launcher)
1266
1345
  if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
1267
1346
  candidates.append(path_bash)
1268
-
1269
1347
  # 4. Hardcoded fallbacks (last resort)
1270
1348
  candidates.extend([
1271
1349
  r'C:\Program Files\Git\usr\bin\bash.exe',
@@ -1273,7 +1351,6 @@ def find_bash_on_windows() -> str | None:
1273
1351
  r'C:\Program Files (x86)\Git\usr\bin\bash.exe',
1274
1352
  r'C:\Program Files (x86)\Git\bin\bash.exe'
1275
1353
  ])
1276
-
1277
1354
  # Find first existing bash
1278
1355
  for bash in candidates:
1279
1356
  if bash and Path(bash).exists():
@@ -1573,24 +1650,12 @@ def setup_hooks() -> bool:
1573
1650
  # Get the hook command template
1574
1651
  hook_cmd_base, _ = get_hook_command()
1575
1652
 
1576
- # Define all hooks - must match ACTIVE_HOOK_TYPES
1577
- # Format: (hook_type, matcher, command, timeout)
1653
+ # Build hook commands from HOOK_CONFIGS
1578
1654
  hook_configs = [
1579
- ('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
1580
- ('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
1581
- ('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
1582
- ('Stop', '', f'{hook_cmd_base} poll', 86400), # 24hr timeout max; internal timeout 30min default via config
1583
- ('Notification', '', f'{hook_cmd_base} notify', None),
1584
- ('SessionEnd', '', f'{hook_cmd_base} sessionend', None),
1655
+ (hook_type, matcher, f'{hook_cmd_base} {cmd_suffix}', timeout)
1656
+ for hook_type, matcher, cmd_suffix, timeout in HOOK_CONFIGS
1585
1657
  ]
1586
1658
 
1587
- # Validate hook_configs matches ACTIVE_HOOK_TYPES
1588
- configured_types = [hook_type for hook_type, _, _, _ in hook_configs]
1589
- if configured_types != ACTIVE_HOOK_TYPES:
1590
- raise Exception(format_error(
1591
- f"Hook configuration mismatch: {configured_types} != {ACTIVE_HOOK_TYPES}"
1592
- ))
1593
-
1594
1659
  for hook_type, matcher, command, timeout in hook_configs:
1595
1660
  if hook_type not in settings['hooks']:
1596
1661
  settings['hooks'][hook_type] = []
@@ -1634,8 +1699,9 @@ def verify_hooks_installed(settings_path: Path) -> bool:
1634
1699
  return False
1635
1700
 
1636
1701
  # Check all hook types have correct commands (exactly one HCOM hook per type)
1702
+ # Derive from HOOK_CONFIGS (single source of truth)
1637
1703
  hooks = settings.get('hooks', {})
1638
- for hook_type, expected_cmd in zip(ACTIVE_HOOK_TYPES, HOOK_COMMANDS):
1704
+ for hook_type, _, cmd_suffix, _ in HOOK_CONFIGS:
1639
1705
  hook_matchers = hooks.get(hook_type, [])
1640
1706
  if not hook_matchers:
1641
1707
  return False
@@ -1646,7 +1712,7 @@ def verify_hooks_installed(settings_path: Path) -> bool:
1646
1712
  for hook in matcher.get('hooks', []):
1647
1713
  command = hook.get('command', '')
1648
1714
  # Check for HCOM and the correct subcommand
1649
- if ('${HCOM}' in command or 'hcom' in command.lower()) and expected_cmd in command:
1715
+ if ('${HCOM}' in command or 'hcom' in command.lower()) and cmd_suffix in command:
1650
1716
  hcom_hook_count += 1
1651
1717
 
1652
1718
  # Must have exactly one HCOM hook (not zero, not duplicates)
@@ -1994,7 +2060,6 @@ def initialize_instance_in_position_file(instance_name: str, session_id: str | N
1994
2060
 
1995
2061
  defaults = {
1996
2062
  "pos": initial_pos,
1997
- "starting_pos": initial_pos,
1998
2063
  "enabled": is_hcom_launched,
1999
2064
  "directory": str(Path.cwd()),
2000
2065
  "last_stop": 0,
@@ -2079,67 +2144,6 @@ def show_main_screen_header() -> list[dict[str, str]]:
2079
2144
  def cmd_help() -> int:
2080
2145
  """Show help text"""
2081
2146
  print(HELP_TEXT)
2082
-
2083
- # Additional help for AI assistants
2084
- if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
2085
- print("""
2086
-
2087
- === ADDITIONAL INFO ===
2088
-
2089
- CONCEPT: HCOM launches Claude Code instances in new terminal windows.
2090
- They communicate with each other via a shared conversation.
2091
- You communicate with them via hcom commands.
2092
-
2093
- KEY UNDERSTANDING:
2094
- • Single conversation - All instances share ~/.hcom/hcom.log
2095
- • Messaging - CLI and instances send with hcom send "message"
2096
- • Instances receive messages via hooks automatically
2097
- • hcom open is directory-specific - always cd to project directory first
2098
- • Named agents are custom system prompt files created by users/claude code beforehand.
2099
- • Named agents load from .claude/agents/<name>.md - if they have been created
2100
- • hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
2101
-
2102
- LAUNCH PATTERNS:
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
2111
-
2112
- @MENTION TARGETING:
2113
- hcom send "message" # Broadcasts to everyone
2114
- hcom send "@api fix this" # Targets all api-* instances (api-hova7, api-kolec)
2115
- hcom send "@hova7 status?" # Targets specific instance
2116
- (Unmatched @mentions broadcast to everyone)
2117
-
2118
- STATUS INDICATORS:
2119
- • ▶ active - processing/executing • ▷ delivered - instance just received a message
2120
- • ◉ idle - waiting for new messages • ■ blocked - permission request (needs user approval)
2121
- • ○ inactive - timed out, disconnected, etc • ○ unknown
2122
-
2123
- CONFIG:
2124
- Config file: ~/.hcom/config.env (KEY=VALUE format)
2125
-
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)
2132
-
2133
-
2134
- EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
2135
- with 'hcom watch --status'. Instances respond automatically in shared chat.
2136
-
2137
- Run 'claude --help' to see all claude code CLI flags.""")
2138
-
2139
- else:
2140
- if not IS_WINDOWS:
2141
- print("\nFor additional info & examples: hcom --help | cat")
2142
-
2143
2147
  return 0
2144
2148
 
2145
2149
  def cmd_launch(argv: list[str]) -> int:
@@ -2172,7 +2176,7 @@ def cmd_launch(argv: list[str]) -> int:
2172
2176
 
2173
2177
  # Get agents from config (comma-separated)
2174
2178
  agent_env = get_config().agent
2175
- agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['generic']
2179
+ agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['']
2176
2180
 
2177
2181
  # Detect background mode from -p/--print flags in forwarded args
2178
2182
  background = '-p' in forwarded or '--print' in forwarded
@@ -2227,8 +2231,8 @@ def cmd_launch(argv: list[str]) -> int:
2227
2231
  instance_env['HCOM_BACKGROUND'] = log_filename
2228
2232
 
2229
2233
  # Build claude command
2230
- if instance_type == 'generic':
2231
- # Generic instance - no agent content
2234
+ if not instance_type:
2235
+ # No agent - no agent content
2232
2236
  claude_cmd, _ = build_claude_command(
2233
2237
  agent_content=None,
2234
2238
  claude_args=claude_args,
@@ -2462,7 +2466,6 @@ def cmd_watch(argv: list[str]) -> int:
2462
2466
  print(" hcom watch --launch Launch interactive dashboard in new terminal", file=sys.stderr)
2463
2467
  print(" Full information: hcom --help")
2464
2468
 
2465
-
2466
2469
  return 0
2467
2470
 
2468
2471
  # Interactive dashboard mode
@@ -2824,23 +2827,12 @@ def cmd_start(argv: list[str]) -> int:
2824
2827
  if session_id and not target:
2825
2828
  instance_name, existing_data = resolve_instance_name(session_id, get_config().tag)
2826
2829
 
2827
- # Check if bootstrap needed (before any state changes)
2828
- needs_bootstrap = not (existing_data and existing_data.get('alias_announced', False))
2829
-
2830
2830
  # Create instance if it doesn't exist (opt-in for vanilla instances)
2831
2831
  if not existing_data:
2832
2832
  initialize_instance_in_position_file(instance_name, session_id)
2833
2833
  # Enable instance (clears all stop flags)
2834
2834
  enable_instance(instance_name)
2835
-
2836
-
2837
-
2838
2835
  print(f"\nStarted HCOM for {instance_name}")
2839
-
2840
- # Show bootstrap for new instances
2841
- if needs_bootstrap:
2842
- print(f"\n\n\n{build_hcom_bootstrap_text(instance_name)}")
2843
- update_instance_position(instance_name, {'alias_announced': True})
2844
2836
  else:
2845
2837
  # Skip already started instances
2846
2838
  if existing_data.get('enabled', False):
@@ -2858,17 +2850,7 @@ def cmd_start(argv: list[str]) -> int:
2858
2850
 
2859
2851
  # Re-enabling existing instance
2860
2852
  enable_instance(instance_name)
2861
- # First time vs rejoining: check if has read messages (pos > starting_pos)
2862
- has_participated = existing_data.get('pos', 0) > existing_data.get('starting_pos', 0)
2863
- if has_participated:
2864
- print(f"\nStarted HCOM for {instance_name}. Rejoined chat.")
2865
- else:
2866
- print(f"\nStarted HCOM for {instance_name}. Joined chat.")
2867
-
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})
2853
+ print(f"Started HCOM for {instance_name}")
2872
2854
 
2873
2855
  return 0
2874
2856
 
@@ -2916,7 +2898,6 @@ def cmd_start(argv: list[str]) -> int:
2916
2898
 
2917
2899
  def cmd_reset(argv: list[str]) -> int:
2918
2900
  """Reset HCOM components: logs, hooks, config
2919
-
2920
2901
  Usage:
2921
2902
  hcom reset # Everything (stop all + logs + hooks + config)
2922
2903
  hcom reset logs # Archive conversation only
@@ -3045,55 +3026,12 @@ def cleanup(*args: str) -> int:
3045
3026
  print(message)
3046
3027
  return exit_code
3047
3028
 
3048
- def is_plugin_active() -> bool:
3049
- """Check if hcom plugin is enabled in Claude Code settings."""
3050
- settings_path = get_claude_settings_path()
3051
- if not settings_path.exists():
3052
- return False
3053
-
3054
- try:
3055
- settings = load_settings_json(settings_path, default={})
3056
- return settings.get('enabledPlugins', {}).get('hcom@hcom', False)
3057
- except Exception:
3058
- return False
3059
-
3060
- def has_direct_hooks_present() -> bool:
3061
- """Check if direct HCOM hooks exist in settings.json
3062
- Direct hooks always set env.HCOM, plugin hooks don't touch settings.json.
3063
- """
3064
- settings_path = get_claude_settings_path()
3065
- if not settings_path.exists():
3066
- return False
3067
- try:
3068
- settings = load_settings_json(settings_path, default=None)
3069
- # Direct hooks marker: HCOM environment variable
3070
- return bool(settings and 'HCOM' in settings.get('env', {}))
3071
- except Exception:
3072
- return False
3073
-
3074
3029
  def ensure_hooks_current() -> bool:
3075
3030
  """Ensure hooks match current execution context - called on EVERY command.
3076
- Manages transition between plugin and direct hooks automatically.
3077
3031
  Auto-updates hooks if execution context changes (e.g., pip → uvx).
3078
3032
  Always returns True (warns but never blocks - Claude Code is fault-tolerant)."""
3079
3033
 
3080
- # Plugin manages hooks?
3081
- if is_plugin_active():
3082
- # Clean up any stale direct hooks (plugin/direct transition)
3083
- if has_direct_hooks_present():
3084
- print("Plugin detected. Cleaning up direct hooks...", file=sys.stderr)
3085
- if remove_global_hooks():
3086
- print("✓ Using plugin hooks exclusively.", file=sys.stderr)
3087
- # Only ask for restart if inside Claude Code
3088
- if os.environ.get('CLAUDECODE') == '1':
3089
- print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
3090
- print("=" * 60, file=sys.stderr)
3091
- else:
3092
- # Failed to remove - warn but continue (plugin hooks still work)
3093
- print("⚠️ Could not remove direct hooks. Check ~/.claude/settings.json", file=sys.stderr)
3094
- return True # Plugin hooks active, all good
3095
-
3096
- # Direct hooks: verify they exist and match current execution context
3034
+ # Verify hooks exist and match current execution context
3097
3035
  global_settings = get_claude_settings_path()
3098
3036
 
3099
3037
  # Check if hooks are valid (exist + env var matches current context)
@@ -3154,7 +3092,7 @@ def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> i
3154
3092
  instances_dir = hcom_path(INSTANCES_DIR)
3155
3093
 
3156
3094
  if not log_file.exists() and not instances_dir.exists():
3157
- print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
3095
+ print(format_error("No conversation found", "Run 'hcom <count>' first"), file=sys.stderr)
3158
3096
  return 1
3159
3097
 
3160
3098
  # Validate message
@@ -3316,7 +3254,7 @@ def pretooluse_decision(decision: str, reason: str) -> None:
3316
3254
  }
3317
3255
  }
3318
3256
  print(json.dumps(output, ensure_ascii=False))
3319
- sys.exit(EXIT_SUCCESS)
3257
+ sys.exit(0)
3320
3258
 
3321
3259
  def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3322
3260
  """Handle PreToolUse hook - check force_closed, inject session_id"""
@@ -3353,7 +3291,7 @@ def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3353
3291
  }
3354
3292
  }
3355
3293
  print(json.dumps(output, ensure_ascii=False))
3356
- sys.exit(EXIT_SUCCESS)
3294
+ sys.exit(0)
3357
3295
 
3358
3296
 
3359
3297
 
@@ -3392,20 +3330,20 @@ def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str
3392
3330
  except (FileNotFoundError, PermissionError):
3393
3331
  # Already deleted or locked, continue anyway
3394
3332
  pass
3395
- sys.exit(EXIT_SUCCESS)
3333
+ sys.exit(0)
3396
3334
 
3397
3335
  # Check if session ended (SessionEnd hook fired) - exit without changing status
3398
3336
  if instance_data.get('session_ended'):
3399
- sys.exit(EXIT_SUCCESS) # Don't overwrite session_ended status
3337
+ sys.exit(0) # Don't overwrite session_ended status
3400
3338
 
3401
3339
  # Check if user input is pending (timestamp fallback) - exit cleanly if recent input
3402
3340
  last_user_input = instance_data.get('last_user_input', 0)
3403
3341
  if time.time() - last_user_input < 0.2:
3404
- sys.exit(EXIT_SUCCESS) # Don't overwrite status - let current status remain
3342
+ sys.exit(0) # Don't overwrite status - let current status remain
3405
3343
 
3406
3344
  # Check if stopped/disabled - exit cleanly
3407
3345
  if not instance_data.get('enabled', False):
3408
- sys.exit(EXIT_SUCCESS) # Preserve 'stopped' status set by cmd_stop
3346
+ sys.exit(0) # Preserve 'stopped' status set by cmd_stop
3409
3347
 
3410
3348
  # Check for new messages and deliver
3411
3349
  if messages := get_unread_messages(instance_name, update_position=True):
@@ -3414,17 +3352,8 @@ def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str
3414
3352
  set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
3415
3353
 
3416
3354
  output = {"decision": "block", "reason": reason}
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)
3355
+ print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
3356
+ sys.exit(2)
3428
3357
 
3429
3358
  # Update heartbeat every 0.5 seconds for staleness detection
3430
3359
  now = time.time()
@@ -3443,11 +3372,12 @@ def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str
3443
3372
 
3444
3373
  # Timeout reached
3445
3374
  set_status(instance_name, 'timeout')
3375
+ sys.exit(0)
3446
3376
 
3447
3377
  except Exception as e:
3448
3378
  # Log error and exit gracefully
3449
3379
  log_hook_error('handle_stop', e)
3450
- sys.exit(EXIT_SUCCESS) # Preserve previous status on exception
3380
+ sys.exit(0) # Preserve previous status on exception
3451
3381
 
3452
3382
  def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3453
3383
  """Handle Notification hook - track permission requests"""
@@ -3457,13 +3387,12 @@ def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[s
3457
3387
 
3458
3388
  def get_user_input_flag_file(instance_name: str) -> Path:
3459
3389
  """Get path to user input coordination flag file"""
3460
- return hcom_path(INSTANCES_DIR, f'{instance_name}.user_input')
3390
+ return hcom_path(FLAGS_DIR, f'{instance_name}.user_input')
3461
3391
 
3462
3392
  def wait_for_stop_exit(instance_name: str, max_wait: float = 0.2) -> int:
3463
3393
  """
3464
3394
  Wait for Stop hook to exit using flag file coordination.
3465
3395
  Returns wait time in ms.
3466
-
3467
3396
  Strategy:
3468
3397
  1. Create flag file
3469
3398
  2. Wait for Stop hook to delete it (proof it exited)
@@ -3484,7 +3413,7 @@ def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updat
3484
3413
  last_stop = instance_data.get('last_stop', 0) if instance_data else 0
3485
3414
  alias_announced = instance_data.get('alias_announced', False) if instance_data else False
3486
3415
 
3487
- # Session_ended prevents user recieving messages(?) so reset it.
3416
+ # Session_ended prevents user receiving messages(?) so reset it.
3488
3417
  if is_matched_resume and instance_data and instance_data.get('session_ended'):
3489
3418
  update_instance_position(instance_name, {'session_ended': False})
3490
3419
  instance_data['session_ended'] = False # Resume path reactivates Stop hook polling
@@ -3533,10 +3462,7 @@ def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updat
3533
3462
  # Add resume status note if we showed bootstrap for a matched resume
3534
3463
  if msg and is_matched_resume:
3535
3464
  if is_enabled:
3536
- msg += "\n[Session resumed. HCOM started for this instance - will receive chat messages. Your alias and conversation history preserved.]"
3537
- else:
3538
- msg += "\n[Session resumed. HCOM stopped for this instance - will not receive chat messages. Run 'hcom start' to rejoin chat. Your alias and conversation history preserved.]"
3539
-
3465
+ msg += "\n[HCOM Session resumed. Your alias and conversation history preserved.]"
3540
3466
  if msg:
3541
3467
  output = {
3542
3468
  "hookSpecificOutput": {
@@ -3549,47 +3475,58 @@ def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updat
3549
3475
  def handle_sessionstart(hook_data: dict[str, Any]) -> None:
3550
3476
  """Handle SessionStart hook - initial msg & reads environment variables"""
3551
3477
  # Only show message for HCOM-launched instances
3552
- if os.environ.get('HCOM_LAUNCHED') != '1':
3553
- return
3478
+ if os.environ.get('HCOM_LAUNCHED') == '1':
3479
+ parts = f"[HCOM is started, you can send messages with the command: {build_hcom_command()} send]"
3480
+ else:
3481
+ parts = f"[You can start HCOM with the command: {build_hcom_command()} start]"
3554
3482
 
3555
- # Build minimal context from environment
3556
- parts = ["[HCOM active]"]
3483
+ output = {
3484
+ "hookSpecificOutput": {
3485
+ "hookEventName": "SessionStart",
3486
+ "additionalContext": parts
3487
+ }
3488
+ }
3557
3489
 
3558
- if agent_type := os.environ.get('HCOM_SUBAGENT_TYPE'):
3559
- parts.append(f"[agent: {agent_type}]")
3490
+ print(json.dumps(output))
3560
3491
 
3561
- if tag := os.environ.get('HCOM_TAG'):
3562
- parts.append(f"[tag: {tag}]")
3492
+ def handle_posttooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3493
+ """Handle PostToolUse hook - show launch context or bootstrap"""
3494
+ command = hook_data.get('tool_input', {}).get('command', '')
3495
+ instance_data = load_instance_position(instance_name)
3563
3496
 
3564
- help_text = " ".join(parts)
3497
+ # Check for help or launch commands (combined pattern)
3498
+ if re.search(r'\bhcom\s+(?:(?:help|--help|-h)\b|\d+)', command):
3499
+ if not instance_data.get('launch_context_announced', False):
3500
+ msg = build_launch_context(instance_name)
3501
+ update_instance_position(instance_name, {'launch_context_announced': True})
3565
3502
 
3566
- # First time: no instance files or archives exist
3567
- is_first_time = not any(hcom_path().rglob('*.json'))
3503
+ output = {
3504
+ "hookSpecificOutput": {
3505
+ "hookEventName": "PostToolUse",
3506
+ "additionalContext": msg
3507
+ }
3508
+ }
3509
+ print(json.dumps(output, ensure_ascii=False))
3510
+ return
3568
3511
 
3569
- is_first_time = True
3570
- if is_first_time:
3571
- help_text += """
3512
+ # Check HCOM_COMMAND_PATTERN for bootstrap (other hcom commands)
3513
+ matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
3572
3514
 
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
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3515
+ if not matches:
3516
+ return
3582
3517
 
3583
- """
3518
+ # Show bootstrap if not announced yet
3519
+ if not instance_data.get('alias_announced', False):
3520
+ msg = build_hcom_bootstrap_text(instance_name)
3521
+ update_instance_position(instance_name, {'alias_announced': True})
3584
3522
 
3585
- output = {
3586
- "hookSpecificOutput": {
3587
- "hookEventName": "SessionStart",
3588
- "additionalContext": help_text
3523
+ output = {
3524
+ "hookSpecificOutput": {
3525
+ "hookEventName": "PostToolUse",
3526
+ "additionalContext": msg
3527
+ }
3589
3528
  }
3590
- }
3591
-
3592
- print(json.dumps(output))
3529
+ print(json.dumps(output, ensure_ascii=False))
3593
3530
 
3594
3531
  def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3595
3532
  """Handle SessionEnd hook - mark session as ended and set final status"""
@@ -3641,16 +3578,16 @@ def handle_hook(hook_type: str) -> None:
3641
3578
 
3642
3579
  if not ensure_hcom_directories():
3643
3580
  log_hook_error('handle_hook', Exception('Failed to create directories'))
3644
- sys.exit(EXIT_SUCCESS)
3581
+ sys.exit(0)
3645
3582
 
3646
3583
  # SessionStart is standalone - no instance files
3647
3584
  if hook_type == 'sessionstart':
3648
3585
  handle_sessionstart(hook_data)
3649
- sys.exit(EXIT_SUCCESS)
3586
+ sys.exit(0)
3650
3587
 
3651
3588
  # Vanilla instance check - exit early if should skip
3652
3589
  if should_skip_vanilla_instance(hook_type, hook_data):
3653
- sys.exit(EXIT_SUCCESS)
3590
+ sys.exit(0)
3654
3591
 
3655
3592
  # Initialize instance context (creates file if needed, reuses existing if session_id matches)
3656
3593
  instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
@@ -3666,11 +3603,13 @@ def handle_hook(hook_type: str) -> None:
3666
3603
  not instance_data.get('alias_announced', False))
3667
3604
 
3668
3605
  if not skip_enabled_check and not instance_data.get('enabled', False):
3669
- sys.exit(EXIT_SUCCESS)
3606
+ sys.exit(0)
3670
3607
 
3671
3608
  match hook_type:
3672
3609
  case 'pre':
3673
3610
  handle_pretooluse(hook_data, instance_name)
3611
+ case 'post':
3612
+ handle_posttooluse(hook_data, instance_name)
3674
3613
  case 'poll':
3675
3614
  handle_stop(hook_data, instance_name, updates, instance_data)
3676
3615
  case 'notify':
@@ -3680,7 +3619,7 @@ def handle_hook(hook_type: str) -> None:
3680
3619
  case 'sessionend':
3681
3620
  handle_sessionend(hook_data, instance_name, updates, instance_data)
3682
3621
 
3683
- sys.exit(EXIT_SUCCESS)
3622
+ sys.exit(0)
3684
3623
 
3685
3624
 
3686
3625
  # ==================== Main Entry Point ====================
@@ -3693,18 +3632,19 @@ def main(argv: list[str] | None = None) -> int | None:
3693
3632
  argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
3694
3633
 
3695
3634
  # Hook handlers only (called BY hooks, not users)
3696
- if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
3635
+ if argv and argv[0] in ('poll', 'notify', 'pre', 'post', 'sessionstart', 'userpromptsubmit', 'sessionend'):
3697
3636
  handle_hook(argv[0])
3698
3637
  return 0
3699
3638
 
3700
- # Check for updates (CLI commands only, not hooks)
3701
- check_version_once_daily()
3702
-
3703
- # Ensure directories exist
3639
+ # Ensure directories exist first (required for version check cache)
3704
3640
  if not ensure_hcom_directories():
3705
3641
  print(format_error("Failed to create HCOM directories"), file=sys.stderr)
3706
3642
  return 1
3707
3643
 
3644
+ # Check for updates and show message if available (once daily check, persists until upgrade)
3645
+ if msg := get_update_notice():
3646
+ print(msg, file=sys.stderr)
3647
+
3708
3648
  # Ensure hooks current (warns but never blocks)
3709
3649
  ensure_hooks_current()
3710
3650