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/__init__.py +1 -1
- hcom/__main__.py +281 -341
- hcom-0.5.0.dist-info/METADATA +257 -0
- hcom-0.5.0.dist-info/RECORD +7 -0
- hcom-0.4.2.post3.dist-info/METADATA +0 -452
- hcom-0.4.2.post3.dist-info/RECORD +0 -7
- {hcom-0.4.2.post3.dist-info → hcom-0.5.0.dist-info}/WHEEL +0 -0
- {hcom-0.4.2.post3.dist-info → hcom-0.5.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.4.2.post3.dist-info → hcom-0.5.0.dist-info}/top_level.txt +0 -0
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
|
|
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
|
|
169
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 = '
|
|
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] ==
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
629
|
-
|
|
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
|
|
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
|
|
643
|
-
"""
|
|
644
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
799
|
-
hcom send "msg" / "@alias msg" / "@tag msg"
|
|
800
|
-
hcom watch --status →
|
|
801
|
-
hcom watch --launch → Open
|
|
802
|
-
hcom start/stop →
|
|
803
|
-
hcom <
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
979
|
-
|
|
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
|
-
|
|
1053
|
+
suffix = suffix_options[letter_hash % len(suffix_options)]
|
|
984
1054
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
#
|
|
999
|
-
if their_session_id
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1577
|
-
# Format: (hook_type, matcher, command, timeout)
|
|
1653
|
+
# Build hook commands from HOOK_CONFIGS
|
|
1578
1654
|
hook_configs = [
|
|
1579
|
-
(
|
|
1580
|
-
|
|
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,
|
|
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
|
|
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 ['
|
|
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
|
|
2231
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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')
|
|
3553
|
-
|
|
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
|
-
|
|
3556
|
-
|
|
3483
|
+
output = {
|
|
3484
|
+
"hookSpecificOutput": {
|
|
3485
|
+
"hookEventName": "SessionStart",
|
|
3486
|
+
"additionalContext": parts
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3557
3489
|
|
|
3558
|
-
|
|
3559
|
-
parts.append(f"[agent: {agent_type}]")
|
|
3490
|
+
print(json.dumps(output))
|
|
3560
3491
|
|
|
3561
|
-
|
|
3562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3567
|
-
|
|
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
|
-
|
|
3570
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
#
|
|
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
|
|