hcom 0.2.2__tar.gz → 0.2.3__tar.gz
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-0.2.2/src/hcom.egg-info → hcom-0.2.3}/PKG-INFO +1 -1
- {hcom-0.2.2 → hcom-0.2.3}/pyproject.toml +1 -1
- {hcom-0.2.2 → hcom-0.2.3}/src/hcom/__init__.py +1 -1
- {hcom-0.2.2 → hcom-0.2.3}/src/hcom/__main__.py +83 -35
- {hcom-0.2.2 → hcom-0.2.3/src/hcom.egg-info}/PKG-INFO +1 -1
- {hcom-0.2.2 → hcom-0.2.3}/MANIFEST.in +0 -0
- {hcom-0.2.2 → hcom-0.2.3}/README.md +0 -0
- {hcom-0.2.2 → hcom-0.2.3}/setup.cfg +0 -0
- {hcom-0.2.2 → hcom-0.2.3}/src/hcom.egg-info/SOURCES.txt +0 -0
- {hcom-0.2.2 → hcom-0.2.3}/src/hcom.egg-info/dependency_links.txt +0 -0
- {hcom-0.2.2 → hcom-0.2.3}/src/hcom.egg-info/entry_points.txt +0 -0
- {hcom-0.2.2 → hcom-0.2.3}/src/hcom.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hcom"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.7"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
hcom 0.2.
|
|
3
|
+
hcom 0.2.3
|
|
4
4
|
CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -93,6 +93,7 @@ def get_windows_kernel32():
|
|
|
93
93
|
return _windows_kernel32_cache
|
|
94
94
|
|
|
95
95
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
96
|
+
AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
|
|
96
97
|
TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
|
|
97
98
|
|
|
98
99
|
RESET = "\033[0m"
|
|
@@ -134,7 +135,7 @@ if IS_WINDOWS or is_wsl():
|
|
|
134
135
|
# Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
|
|
135
136
|
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
136
137
|
|
|
137
|
-
# ====================
|
|
138
|
+
# ==================== Config Defaults ====================
|
|
138
139
|
|
|
139
140
|
DEFAULT_CONFIG = {
|
|
140
141
|
"terminal_command": None,
|
|
@@ -242,8 +243,15 @@ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
|
|
|
242
243
|
return default
|
|
243
244
|
|
|
244
245
|
def get_instance_file(instance_name):
|
|
245
|
-
"""Get path to instance's position file"""
|
|
246
|
-
|
|
246
|
+
"""Get path to instance's position file with path traversal protection"""
|
|
247
|
+
# Sanitize instance name to prevent directory traversal
|
|
248
|
+
if not instance_name:
|
|
249
|
+
instance_name = "unknown"
|
|
250
|
+
safe_name = instance_name.replace('..', '').replace('/', '-').replace('\\', '-').replace(os.sep, '-')
|
|
251
|
+
if not safe_name:
|
|
252
|
+
safe_name = "sanitized"
|
|
253
|
+
|
|
254
|
+
return hcom_path(INSTANCES_DIR, f"{safe_name}.json")
|
|
247
255
|
|
|
248
256
|
def migrate_instance_data_v020(data, instance_name):
|
|
249
257
|
"""One-time migration from v0.2.0 format (remove in v0.3.0)"""
|
|
@@ -376,14 +384,17 @@ def get_config_value(key, default=None):
|
|
|
376
384
|
env_var = HOOK_SETTINGS[key]
|
|
377
385
|
env_value = os.environ.get(env_var)
|
|
378
386
|
if env_value is not None:
|
|
387
|
+
# Type conversion based on key
|
|
379
388
|
if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
|
|
380
389
|
try:
|
|
381
390
|
return int(env_value)
|
|
382
391
|
except ValueError:
|
|
392
|
+
# Invalid integer - fall through to config/default
|
|
383
393
|
pass
|
|
384
|
-
elif key == 'auto_watch':
|
|
394
|
+
elif key == 'auto_watch':
|
|
385
395
|
return env_value.lower() in ('true', '1', 'yes', 'on')
|
|
386
396
|
else:
|
|
397
|
+
# String values - return as-is
|
|
387
398
|
return env_value
|
|
388
399
|
|
|
389
400
|
config = get_cached_config()
|
|
@@ -509,7 +520,7 @@ def should_deliver_message(msg, instance_name, all_instance_names=None):
|
|
|
509
520
|
|
|
510
521
|
return False # This instance doesn't match, but others might
|
|
511
522
|
|
|
512
|
-
# ==================== Parsing
|
|
523
|
+
# ==================== Parsing & Utilities ====================
|
|
513
524
|
|
|
514
525
|
def parse_open_args(args):
|
|
515
526
|
"""Parse arguments for open command
|
|
@@ -596,31 +607,73 @@ def extract_agent_config(content):
|
|
|
596
607
|
return config
|
|
597
608
|
|
|
598
609
|
def resolve_agent(name):
|
|
599
|
-
"""Resolve agent file by name
|
|
600
|
-
|
|
610
|
+
"""Resolve agent file by name with validation.
|
|
611
|
+
|
|
601
612
|
Looks for agent files in:
|
|
602
613
|
1. .claude/agents/{name}.md (local)
|
|
603
614
|
2. ~/.claude/agents/{name}.md (global)
|
|
604
|
-
|
|
605
|
-
Returns tuple: (content
|
|
615
|
+
|
|
616
|
+
Returns tuple: (content without YAML frontmatter, config dict)
|
|
606
617
|
"""
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
618
|
+
hint = 'Agent names must use lowercase letters and dashes only'
|
|
619
|
+
|
|
620
|
+
if not isinstance(name, str):
|
|
621
|
+
raise FileNotFoundError(format_error(
|
|
622
|
+
f"Agent '{name}' not found",
|
|
623
|
+
hint
|
|
624
|
+
))
|
|
625
|
+
|
|
626
|
+
candidate = name.strip()
|
|
627
|
+
display_name = candidate or name
|
|
628
|
+
|
|
629
|
+
if not candidate or not AGENT_NAME_PATTERN.fullmatch(candidate):
|
|
630
|
+
raise FileNotFoundError(format_error(
|
|
631
|
+
f"Agent '{display_name}' not found",
|
|
632
|
+
hint
|
|
633
|
+
))
|
|
634
|
+
|
|
635
|
+
for base_path in (Path.cwd(), Path.home()):
|
|
636
|
+
agents_dir = base_path / '.claude' / 'agents'
|
|
637
|
+
try:
|
|
638
|
+
agents_dir_resolved = agents_dir.resolve(strict=True)
|
|
639
|
+
except FileNotFoundError:
|
|
640
|
+
continue
|
|
641
|
+
|
|
642
|
+
agent_path = agents_dir / f'{candidate}.md'
|
|
643
|
+
if not agent_path.exists():
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
resolved_agent_path = agent_path.resolve(strict=True)
|
|
648
|
+
except FileNotFoundError:
|
|
649
|
+
continue
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
resolved_agent_path.relative_to(agents_dir_resolved)
|
|
653
|
+
except ValueError:
|
|
654
|
+
continue
|
|
655
|
+
|
|
656
|
+
content = read_file_with_retry(
|
|
657
|
+
agent_path,
|
|
658
|
+
lambda f: f.read(),
|
|
659
|
+
default=None
|
|
660
|
+
)
|
|
661
|
+
if content is None:
|
|
662
|
+
continue
|
|
663
|
+
|
|
664
|
+
config = extract_agent_config(content)
|
|
665
|
+
stripped = strip_frontmatter(content)
|
|
666
|
+
if not stripped.strip():
|
|
667
|
+
raise ValueError(format_error(
|
|
668
|
+
f"Agent '{candidate}' has empty content",
|
|
669
|
+
'Check the agent file is a valid format and contains text'
|
|
670
|
+
))
|
|
671
|
+
return stripped, config
|
|
672
|
+
|
|
673
|
+
raise FileNotFoundError(format_error(
|
|
674
|
+
f"Agent '{candidate}' not found in project or user .claude/agents/ folder",
|
|
675
|
+
'Check available agents or create the agent file'
|
|
676
|
+
))
|
|
624
677
|
|
|
625
678
|
def strip_frontmatter(content):
|
|
626
679
|
"""Strip YAML frontmatter from agent file"""
|
|
@@ -1036,18 +1089,14 @@ def _parse_terminal_command(template, script_file):
|
|
|
1036
1089
|
|
|
1037
1090
|
return replaced
|
|
1038
1091
|
|
|
1039
|
-
def launch_terminal(command, env,
|
|
1092
|
+
def launch_terminal(command, env, cwd=None, background=False):
|
|
1040
1093
|
"""Launch terminal with command using unified script-first approach
|
|
1041
1094
|
Args:
|
|
1042
1095
|
command: Command string from build_claude_command
|
|
1043
1096
|
env: Environment variables to set
|
|
1044
|
-
config: Configuration dict
|
|
1045
1097
|
cwd: Working directory
|
|
1046
1098
|
background: Launch as background process
|
|
1047
1099
|
"""
|
|
1048
|
-
if config is None:
|
|
1049
|
-
config = get_cached_config()
|
|
1050
|
-
|
|
1051
1100
|
env_vars = os.environ.copy()
|
|
1052
1101
|
env_vars.update(env)
|
|
1053
1102
|
command_str = command
|
|
@@ -2282,10 +2331,9 @@ def cmd_watch(*args):
|
|
|
2282
2331
|
|
|
2283
2332
|
show_recent_messages(all_messages, limit=5)
|
|
2284
2333
|
print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
|
|
2285
|
-
print(f"{DIM}{'─' * 40}{RESET}")
|
|
2286
2334
|
|
|
2287
2335
|
# Print newline to ensure status starts on its own line
|
|
2288
|
-
|
|
2336
|
+
print()
|
|
2289
2337
|
|
|
2290
2338
|
current_status = get_status_summary()
|
|
2291
2339
|
update_status(f"{current_status}{status_suffix}")
|
|
@@ -2636,7 +2684,7 @@ def cmd_send(message):
|
|
|
2636
2684
|
print(format_error("Failed to send message"), file=sys.stderr)
|
|
2637
2685
|
return 1
|
|
2638
2686
|
|
|
2639
|
-
# ==================== Hook
|
|
2687
|
+
# ==================== Hook Helpers ====================
|
|
2640
2688
|
|
|
2641
2689
|
def format_hook_messages(messages, instance_name):
|
|
2642
2690
|
"""Format messages for hook feedback"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|