hcom 0.2.1__py3-none-any.whl → 0.2.3__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 +396 -301
- {hcom-0.2.1.dist-info → hcom-0.2.3.dist-info}/METADATA +93 -67
- hcom-0.2.3.dist-info/RECORD +7 -0
- hcom-0.2.1.dist-info/RECORD +0 -7
- {hcom-0.2.1.dist-info → hcom-0.2.3.dist-info}/WHEEL +0 -0
- {hcom-0.2.1.dist-info → hcom-0.2.3.dist-info}/entry_points.txt +0 -0
- {hcom-0.2.1.dist-info → hcom-0.2.3.dist-info}/top_level.txt +0 -0
hcom/__main__.py
CHANGED
|
@@ -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
|
|
|
@@ -14,7 +14,6 @@ import re
|
|
|
14
14
|
import subprocess
|
|
15
15
|
import time
|
|
16
16
|
import select
|
|
17
|
-
import threading
|
|
18
17
|
import platform
|
|
19
18
|
import random
|
|
20
19
|
from pathlib import Path
|
|
@@ -34,6 +33,15 @@ def is_wsl():
|
|
|
34
33
|
except:
|
|
35
34
|
return False
|
|
36
35
|
|
|
36
|
+
def is_termux():
|
|
37
|
+
"""Detect if running in Termux on Android"""
|
|
38
|
+
return (
|
|
39
|
+
'TERMUX_VERSION' in os.environ or # Primary: Works all versions
|
|
40
|
+
'TERMUX__ROOTFS' in os.environ or # Modern: v0.119.0+
|
|
41
|
+
os.path.exists('/data/data/com.termux') or # Fallback: Path check
|
|
42
|
+
'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
|
|
43
|
+
)
|
|
44
|
+
|
|
37
45
|
HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
|
|
38
46
|
HCOM_ACTIVE_VALUE = '1'
|
|
39
47
|
|
|
@@ -85,6 +93,7 @@ def get_windows_kernel32():
|
|
|
85
93
|
return _windows_kernel32_cache
|
|
86
94
|
|
|
87
95
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
96
|
+
AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
|
|
88
97
|
TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
|
|
89
98
|
|
|
90
99
|
RESET = "\033[0m"
|
|
@@ -126,7 +135,7 @@ if IS_WINDOWS or is_wsl():
|
|
|
126
135
|
# Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
|
|
127
136
|
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
128
137
|
|
|
129
|
-
# ====================
|
|
138
|
+
# ==================== Config Defaults ====================
|
|
130
139
|
|
|
131
140
|
DEFAULT_CONFIG = {
|
|
132
141
|
"terminal_command": None,
|
|
@@ -165,6 +174,7 @@ HOOK_SETTINGS = {
|
|
|
165
174
|
LOG_FILE = "hcom.log"
|
|
166
175
|
INSTANCES_DIR = "instances"
|
|
167
176
|
LOGS_DIR = "logs"
|
|
177
|
+
SCRIPTS_DIR = "scripts"
|
|
168
178
|
CONFIG_FILE = "config.json"
|
|
169
179
|
ARCHIVE_DIR = "archive"
|
|
170
180
|
|
|
@@ -233,8 +243,15 @@ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
|
|
|
233
243
|
return default
|
|
234
244
|
|
|
235
245
|
def get_instance_file(instance_name):
|
|
236
|
-
"""Get path to instance's position file"""
|
|
237
|
-
|
|
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")
|
|
238
255
|
|
|
239
256
|
def migrate_instance_data_v020(data, instance_name):
|
|
240
257
|
"""One-time migration from v0.2.0 format (remove in v0.3.0)"""
|
|
@@ -367,14 +384,17 @@ def get_config_value(key, default=None):
|
|
|
367
384
|
env_var = HOOK_SETTINGS[key]
|
|
368
385
|
env_value = os.environ.get(env_var)
|
|
369
386
|
if env_value is not None:
|
|
387
|
+
# Type conversion based on key
|
|
370
388
|
if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
|
|
371
389
|
try:
|
|
372
390
|
return int(env_value)
|
|
373
391
|
except ValueError:
|
|
392
|
+
# Invalid integer - fall through to config/default
|
|
374
393
|
pass
|
|
375
|
-
elif key == 'auto_watch':
|
|
394
|
+
elif key == 'auto_watch':
|
|
376
395
|
return env_value.lower() in ('true', '1', 'yes', 'on')
|
|
377
396
|
else:
|
|
397
|
+
# String values - return as-is
|
|
378
398
|
return env_value
|
|
379
399
|
|
|
380
400
|
config = get_cached_config()
|
|
@@ -396,8 +416,8 @@ def get_hook_command():
|
|
|
396
416
|
return f'IF "%HCOM_ACTIVE%"=="1" {python_path} {script_path}', {}
|
|
397
417
|
elif ' ' in python_path or ' ' in script_path:
|
|
398
418
|
# Unix with spaces: use conditional check
|
|
399
|
-
escaped_python =
|
|
400
|
-
escaped_script =
|
|
419
|
+
escaped_python = shlex.quote(python_path)
|
|
420
|
+
escaped_script = shlex.quote(script_path)
|
|
401
421
|
return f'[ "${{HCOM_ACTIVE}}" = "1" ] && {escaped_python} {escaped_script} || true', {}
|
|
402
422
|
else:
|
|
403
423
|
# Unix clean paths: use environment variable
|
|
@@ -500,7 +520,7 @@ def should_deliver_message(msg, instance_name, all_instance_names=None):
|
|
|
500
520
|
|
|
501
521
|
return False # This instance doesn't match, but others might
|
|
502
522
|
|
|
503
|
-
# ==================== Parsing
|
|
523
|
+
# ==================== Parsing & Utilities ====================
|
|
504
524
|
|
|
505
525
|
def parse_open_args(args):
|
|
506
526
|
"""Parse arguments for open command
|
|
@@ -526,7 +546,7 @@ def parse_open_args(args):
|
|
|
526
546
|
raise ValueError(format_error('--prefix requires an argument'))
|
|
527
547
|
prefix = args[i + 1]
|
|
528
548
|
if '|' in prefix:
|
|
529
|
-
raise ValueError(format_error('
|
|
549
|
+
raise ValueError(format_error('Prefix cannot contain pipe characters'))
|
|
530
550
|
i += 2
|
|
531
551
|
elif arg == '--claude-args':
|
|
532
552
|
# Next argument contains claude args as a string
|
|
@@ -587,31 +607,73 @@ def extract_agent_config(content):
|
|
|
587
607
|
return config
|
|
588
608
|
|
|
589
609
|
def resolve_agent(name):
|
|
590
|
-
"""Resolve agent file by name
|
|
591
|
-
|
|
610
|
+
"""Resolve agent file by name with validation.
|
|
611
|
+
|
|
592
612
|
Looks for agent files in:
|
|
593
613
|
1. .claude/agents/{name}.md (local)
|
|
594
614
|
2. ~/.claude/agents/{name}.md (global)
|
|
595
|
-
|
|
596
|
-
Returns tuple: (content
|
|
615
|
+
|
|
616
|
+
Returns tuple: (content without YAML frontmatter, config dict)
|
|
597
617
|
"""
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
+
))
|
|
615
677
|
|
|
616
678
|
def strip_frontmatter(content):
|
|
617
679
|
"""Strip YAML frontmatter from agent file"""
|
|
@@ -766,7 +828,6 @@ def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
|
766
828
|
|
|
767
829
|
def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat", model=None, tools=None):
|
|
768
830
|
"""Build Claude command with proper argument handling
|
|
769
|
-
|
|
770
831
|
Returns tuple: (command_string, temp_file_path_or_none)
|
|
771
832
|
For agent content, writes to temp file and uses cat to read it.
|
|
772
833
|
"""
|
|
@@ -789,8 +850,11 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
789
850
|
cmd_parts.append(shlex.quote(arg))
|
|
790
851
|
|
|
791
852
|
if agent_content:
|
|
853
|
+
# Create agent files in scripts directory for unified cleanup
|
|
854
|
+
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
855
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
792
856
|
temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
|
|
793
|
-
prefix='hcom_agent_', dir=
|
|
857
|
+
prefix='hcom_agent_', dir=str(scripts_dir))
|
|
794
858
|
temp_file.write(agent_content)
|
|
795
859
|
temp_file.close()
|
|
796
860
|
temp_file_path = temp_file.name
|
|
@@ -807,34 +871,18 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
807
871
|
cmd_parts.append('--')
|
|
808
872
|
|
|
809
873
|
# Quote initial prompt normally
|
|
810
|
-
cmd_parts.append(
|
|
874
|
+
cmd_parts.append(shlex.quote(initial_prompt))
|
|
811
875
|
|
|
812
876
|
return ' '.join(cmd_parts), temp_file_path
|
|
813
877
|
|
|
814
|
-
def escape_for_platform(text, platform_type):
|
|
815
|
-
"""Centralized escaping for different platforms"""
|
|
816
|
-
if platform_type == 'applescript':
|
|
817
|
-
# AppleScript escaping for text within double quotes
|
|
818
|
-
# We need to escape backslashes first, then other special chars
|
|
819
|
-
return (text.replace('\\', '\\\\')
|
|
820
|
-
.replace('"', '\\"') # Escape double quotes
|
|
821
|
-
.replace('\n', '\\n') # Escape newlines
|
|
822
|
-
.replace('\r', '\\r') # Escape carriage returns
|
|
823
|
-
.replace('\t', '\\t')) # Escape tabs
|
|
824
|
-
else: # POSIX/bash
|
|
825
|
-
return shlex.quote(text)
|
|
826
|
-
|
|
827
|
-
def shell_quote(text):
|
|
828
|
-
"""Cross-platform shell argument quoting
|
|
829
|
-
|
|
830
|
-
Note: On Windows with Git Bash, subprocess.Popen(shell=True) uses bash, not cmd.exe
|
|
831
|
-
"""
|
|
832
|
-
# Always use shlex.quote for proper bash escaping
|
|
833
|
-
# Git Bash on Windows uses bash as the shell
|
|
834
|
-
return shlex.quote(text)
|
|
835
|
-
|
|
836
878
|
def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
837
|
-
"""Create a bash script for terminal launch
|
|
879
|
+
"""Create a bash script for terminal launch
|
|
880
|
+
Scripts provide uniform execution across all platforms/terminals.
|
|
881
|
+
Cleanup behavior:
|
|
882
|
+
- Normal scripts: append 'rm -f' command for self-deletion
|
|
883
|
+
- Background scripts: persist until `hcom clear` housekeeping (24 hours)
|
|
884
|
+
- Agent scripts: treated like background (contain 'hcom_agent_')
|
|
885
|
+
"""
|
|
838
886
|
try:
|
|
839
887
|
# Ensure parent directory exists
|
|
840
888
|
script_path = Path(script_file)
|
|
@@ -845,15 +893,52 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
|
845
893
|
with open(script_file, 'w', encoding='utf-8') as f:
|
|
846
894
|
f.write('#!/bin/bash\n')
|
|
847
895
|
f.write('echo "Starting Claude Code..."\n')
|
|
848
|
-
f.write(build_env_string(env, "bash_export") + '\n')
|
|
849
|
-
if cwd:
|
|
850
|
-
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
851
896
|
|
|
852
|
-
# On Windows, let bash resolve claude from PATH (Windows paths don't work in bash)
|
|
853
897
|
if platform.system() != 'Windows':
|
|
898
|
+
# 1. Discover paths once
|
|
854
899
|
claude_path = shutil.which('claude')
|
|
900
|
+
node_path = shutil.which('node')
|
|
901
|
+
|
|
902
|
+
# 2. Add to PATH for minimal environments
|
|
903
|
+
paths_to_add = []
|
|
904
|
+
for p in [node_path, claude_path]:
|
|
905
|
+
if p:
|
|
906
|
+
dir_path = os.path.dirname(os.path.realpath(p))
|
|
907
|
+
if dir_path not in paths_to_add:
|
|
908
|
+
paths_to_add.append(dir_path)
|
|
909
|
+
|
|
910
|
+
if paths_to_add:
|
|
911
|
+
path_addition = ':'.join(paths_to_add)
|
|
912
|
+
f.write(f'export PATH="{path_addition}:$PATH"\n')
|
|
913
|
+
elif not claude_path:
|
|
914
|
+
# Warning for debugging
|
|
915
|
+
print("Warning: Could not locate 'claude' in PATH", file=sys.stderr)
|
|
916
|
+
|
|
917
|
+
# 3. Write environment variables
|
|
918
|
+
f.write(build_env_string(env, "bash_export") + '\n')
|
|
919
|
+
|
|
920
|
+
if cwd:
|
|
921
|
+
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
922
|
+
|
|
923
|
+
# 4. Platform-specific command modifications
|
|
855
924
|
if claude_path:
|
|
856
|
-
|
|
925
|
+
if is_termux():
|
|
926
|
+
# Termux: explicit node to bypass shebang issues
|
|
927
|
+
final_node = node_path or '/data/data/com.termux/files/usr/bin/node'
|
|
928
|
+
# Quote paths for safety
|
|
929
|
+
command_str = command_str.replace(
|
|
930
|
+
'claude ',
|
|
931
|
+
f'{shlex.quote(final_node)} {shlex.quote(claude_path)} ',
|
|
932
|
+
1
|
|
933
|
+
)
|
|
934
|
+
else:
|
|
935
|
+
# Mac/Linux: use full path (PATH now has node if needed)
|
|
936
|
+
command_str = command_str.replace('claude ', f'{shlex.quote(claude_path)} ', 1)
|
|
937
|
+
else:
|
|
938
|
+
# Windows: no PATH modification needed
|
|
939
|
+
f.write(build_env_string(env, "bash_export") + '\n')
|
|
940
|
+
if cwd:
|
|
941
|
+
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
857
942
|
|
|
858
943
|
f.write(f'{command_str}\n')
|
|
859
944
|
|
|
@@ -908,258 +993,262 @@ def find_bash_on_windows():
|
|
|
908
993
|
|
|
909
994
|
return None
|
|
910
995
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
996
|
+
# New helper functions for platform-specific terminal launching
|
|
997
|
+
def get_macos_terminal_argv():
|
|
998
|
+
"""Return macOS Terminal.app launch command as argv list."""
|
|
999
|
+
return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
|
|
1000
|
+
|
|
1001
|
+
def get_windows_terminal_argv():
|
|
1002
|
+
"""Return Windows terminal launcher as argv list."""
|
|
1003
|
+
bash_exe = find_bash_on_windows()
|
|
1004
|
+
if not bash_exe:
|
|
1005
|
+
raise Exception(format_error("Git Bash not found"))
|
|
1006
|
+
|
|
1007
|
+
if shutil.which('wt'):
|
|
1008
|
+
return ['wt', bash_exe, '{script}']
|
|
1009
|
+
return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
|
|
1010
|
+
|
|
1011
|
+
def get_linux_terminal_argv():
|
|
1012
|
+
"""Return first available Linux terminal as argv list."""
|
|
1013
|
+
terminals = [
|
|
1014
|
+
('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
|
|
1015
|
+
('konsole', ['konsole', '-e', 'bash', '{script}']),
|
|
1016
|
+
('xterm', ['xterm', '-e', 'bash', '{script}']),
|
|
1017
|
+
]
|
|
1018
|
+
for term_name, argv_template in terminals:
|
|
1019
|
+
if shutil.which(term_name):
|
|
1020
|
+
return argv_template
|
|
915
1021
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1022
|
+
# WSL fallback integrated here
|
|
1023
|
+
if is_wsl() and shutil.which('cmd.exe'):
|
|
1024
|
+
if shutil.which('wt.exe'):
|
|
1025
|
+
return ['cmd.exe', '/c', 'start', 'wt.exe', 'bash', '{script}']
|
|
1026
|
+
return ['cmd.exe', '/c', 'start', 'bash', '{script}']
|
|
1027
|
+
|
|
1028
|
+
return None
|
|
1029
|
+
|
|
1030
|
+
def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
|
|
1031
|
+
"""Create hidden Windows process without console window."""
|
|
1032
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
1033
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
1034
|
+
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
1035
|
+
|
|
1036
|
+
return subprocess.Popen(
|
|
1037
|
+
argv,
|
|
1038
|
+
env=env,
|
|
1039
|
+
cwd=cwd,
|
|
1040
|
+
stdin=subprocess.DEVNULL,
|
|
1041
|
+
stdout=stdout,
|
|
1042
|
+
stderr=subprocess.STDOUT,
|
|
1043
|
+
startupinfo=startupinfo,
|
|
1044
|
+
creationflags=CREATE_NO_WINDOW
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
# Platform dispatch map
|
|
1048
|
+
PLATFORM_TERMINAL_GETTERS = {
|
|
1049
|
+
'Darwin': get_macos_terminal_argv,
|
|
1050
|
+
'Windows': get_windows_terminal_argv,
|
|
1051
|
+
'Linux': get_linux_terminal_argv,
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
def _parse_terminal_command(template, script_file):
|
|
1055
|
+
"""Parse terminal command template safely to prevent shell injection.
|
|
1056
|
+
Parses the template FIRST, then replaces {script} placeholder in the
|
|
1057
|
+
parsed tokens. This avoids shell injection and handles paths with spaces.
|
|
1058
|
+
Args:
|
|
1059
|
+
template: Terminal command template with {script} placeholder
|
|
1060
|
+
script_file: Path to script file to substitute
|
|
1061
|
+
Returns:
|
|
1062
|
+
list: Parsed command as argv array
|
|
1063
|
+
Raises:
|
|
1064
|
+
ValueError: If template is invalid or missing {script} placeholder
|
|
1065
|
+
"""
|
|
1066
|
+
if '{script}' not in template:
|
|
1067
|
+
raise ValueError(format_error("Custom terminal command must include {script} placeholder",
|
|
1068
|
+
'Example: open -n -a kitty.app --args bash "{script}"'))
|
|
1069
|
+
|
|
1070
|
+
try:
|
|
1071
|
+
parts = shlex.split(template)
|
|
1072
|
+
except ValueError as e:
|
|
1073
|
+
raise ValueError(format_error(f"Invalid terminal command syntax: {e}",
|
|
1074
|
+
"Check for unmatched quotes or invalid shell syntax"))
|
|
1075
|
+
|
|
1076
|
+
# Replace {script} in parsed tokens
|
|
1077
|
+
replaced = []
|
|
1078
|
+
placeholder_found = False
|
|
1079
|
+
for part in parts:
|
|
1080
|
+
if '{script}' in part:
|
|
1081
|
+
replaced.append(part.replace('{script}', script_file))
|
|
1082
|
+
placeholder_found = True
|
|
1083
|
+
else:
|
|
1084
|
+
replaced.append(part)
|
|
1085
|
+
|
|
1086
|
+
if not placeholder_found:
|
|
1087
|
+
raise ValueError(format_error("{script} placeholder not found after parsing",
|
|
1088
|
+
"Ensure {script} is not inside environment variables"))
|
|
923
1089
|
|
|
924
|
-
|
|
925
|
-
thread.start()
|
|
1090
|
+
return replaced
|
|
926
1091
|
|
|
927
|
-
def launch_terminal(command, env,
|
|
928
|
-
"""Launch terminal with command
|
|
1092
|
+
def launch_terminal(command, env, cwd=None, background=False):
|
|
1093
|
+
"""Launch terminal with command using unified script-first approach
|
|
929
1094
|
Args:
|
|
930
|
-
command:
|
|
1095
|
+
command: Command string from build_claude_command
|
|
931
1096
|
env: Environment variables to set
|
|
932
|
-
config: Configuration dict
|
|
933
1097
|
cwd: Working directory
|
|
934
1098
|
background: Launch as background process
|
|
935
1099
|
"""
|
|
936
|
-
|
|
937
|
-
if config is None:
|
|
938
|
-
config = get_cached_config()
|
|
939
|
-
|
|
940
1100
|
env_vars = os.environ.copy()
|
|
941
1101
|
env_vars.update(env)
|
|
942
|
-
|
|
943
|
-
# Command should now always be a string from build_claude_command
|
|
944
1102
|
command_str = command
|
|
945
|
-
|
|
946
|
-
#
|
|
1103
|
+
|
|
1104
|
+
# 1) Always create a script
|
|
1105
|
+
script_file = str(hcom_path(SCRIPTS_DIR,
|
|
1106
|
+
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh',
|
|
1107
|
+
ensure_parent=True))
|
|
1108
|
+
create_bash_script(script_file, env, cwd, command_str, background)
|
|
1109
|
+
|
|
1110
|
+
# 2) Background mode
|
|
947
1111
|
if background:
|
|
948
|
-
# Create log file for background instance
|
|
949
1112
|
logs_dir = hcom_path(LOGS_DIR)
|
|
950
1113
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
951
1114
|
log_file = logs_dir / env['HCOM_BACKGROUND']
|
|
952
1115
|
|
|
953
|
-
# Launch detached process
|
|
954
1116
|
try:
|
|
955
1117
|
with open(log_file, 'w', encoding='utf-8') as log_handle:
|
|
956
1118
|
if IS_WINDOWS:
|
|
957
|
-
# Windows:
|
|
1119
|
+
# Windows: hidden bash execution with Python-piped logs
|
|
958
1120
|
bash_exe = find_bash_on_windows()
|
|
959
1121
|
if not bash_exe:
|
|
960
1122
|
raise Exception("Git Bash not found")
|
|
961
1123
|
|
|
962
|
-
|
|
963
|
-
script_file = str(hcom_path('scripts',
|
|
964
|
-
f'background_{os.getpid()}_{random.randint(1000,9999)}.sh',
|
|
965
|
-
ensure_parent=True))
|
|
966
|
-
|
|
967
|
-
create_bash_script(script_file, env, cwd, command_str, background=True)
|
|
968
|
-
|
|
969
|
-
# Windows requires STARTUPINFO to hide console window
|
|
970
|
-
startupinfo = subprocess.STARTUPINFO()
|
|
971
|
-
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
972
|
-
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
973
|
-
|
|
974
|
-
# Use bash.exe directly with script, avoiding shell=True and cmd.exe
|
|
975
|
-
process = subprocess.Popen(
|
|
1124
|
+
process = windows_hidden_popen(
|
|
976
1125
|
[bash_exe, script_file],
|
|
977
1126
|
env=env_vars,
|
|
978
1127
|
cwd=cwd,
|
|
979
|
-
|
|
980
|
-
stdout=log_handle,
|
|
981
|
-
stderr=subprocess.STDOUT,
|
|
982
|
-
startupinfo=startupinfo,
|
|
983
|
-
creationflags=CREATE_NO_WINDOW # Belt and suspenders approach
|
|
1128
|
+
stdout=log_handle
|
|
984
1129
|
)
|
|
985
1130
|
else:
|
|
986
|
-
# Unix:
|
|
1131
|
+
# Unix(Mac/Linux/Termux): detached bash execution with Python-piped logs
|
|
987
1132
|
process = subprocess.Popen(
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
env=env_vars,
|
|
991
|
-
cwd=cwd,
|
|
1133
|
+
['bash', script_file],
|
|
1134
|
+
env=env_vars, cwd=cwd,
|
|
992
1135
|
stdin=subprocess.DEVNULL,
|
|
993
|
-
stdout=log_handle,
|
|
994
|
-
|
|
995
|
-
start_new_session=True # detach from terminal session
|
|
1136
|
+
stdout=log_handle, stderr=subprocess.STDOUT,
|
|
1137
|
+
start_new_session=True
|
|
996
1138
|
)
|
|
1139
|
+
|
|
997
1140
|
except OSError as e:
|
|
998
1141
|
print(format_error(f"Failed to launch background instance: {e}"), file=sys.stderr)
|
|
999
1142
|
return None
|
|
1000
|
-
|
|
1001
|
-
#
|
|
1143
|
+
|
|
1144
|
+
# Health check
|
|
1002
1145
|
time.sleep(0.2)
|
|
1003
1146
|
if process.poll() is not None:
|
|
1004
|
-
|
|
1005
|
-
error_output = read_file_with_retry(
|
|
1006
|
-
log_file,
|
|
1007
|
-
lambda f: f.read()[:1000],
|
|
1008
|
-
default=""
|
|
1009
|
-
)
|
|
1147
|
+
error_output = read_file_with_retry(log_file, lambda f: f.read()[:1000], default="")
|
|
1010
1148
|
print(format_error("Background instance failed immediately"), file=sys.stderr)
|
|
1011
1149
|
if error_output:
|
|
1012
1150
|
print(f" Output: {error_output}", file=sys.stderr)
|
|
1013
1151
|
return None
|
|
1014
|
-
|
|
1152
|
+
|
|
1015
1153
|
return str(log_file)
|
|
1016
1154
|
|
|
1155
|
+
# 3) Terminal modes
|
|
1017
1156
|
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
1018
1157
|
|
|
1019
1158
|
if terminal_mode == 'show_commands':
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1159
|
+
# Print script path and contents
|
|
1160
|
+
try:
|
|
1161
|
+
with open(script_file, 'r', encoding='utf-8') as f:
|
|
1162
|
+
script_content = f.read()
|
|
1163
|
+
print(f"# Script: {script_file}")
|
|
1164
|
+
print(script_content)
|
|
1165
|
+
os.unlink(script_file) # Clean up immediately
|
|
1166
|
+
return True
|
|
1167
|
+
except Exception as e:
|
|
1168
|
+
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
1169
|
+
return False
|
|
1023
1170
|
|
|
1024
|
-
|
|
1025
|
-
print(
|
|
1171
|
+
if terminal_mode == 'same_terminal':
|
|
1172
|
+
print("Launching Claude in current terminal...")
|
|
1026
1173
|
if IS_WINDOWS:
|
|
1027
|
-
# Windows: Use bash directly to support $(cat ...) syntax
|
|
1028
1174
|
bash_exe = find_bash_on_windows()
|
|
1029
1175
|
if not bash_exe:
|
|
1030
1176
|
print(format_error("Git Bash not found"), file=sys.stderr)
|
|
1031
1177
|
return False
|
|
1032
|
-
|
|
1033
|
-
result = subprocess.run([bash_exe, '-c', command_str], env=env_vars, cwd=cwd)
|
|
1178
|
+
result = subprocess.run([bash_exe, script_file], env=env_vars, cwd=cwd)
|
|
1034
1179
|
else:
|
|
1035
|
-
|
|
1036
|
-
result = subprocess.run(command_str, shell=True, env=env_vars, cwd=cwd)
|
|
1037
|
-
|
|
1180
|
+
result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
|
|
1038
1181
|
return result.returncode == 0
|
|
1039
|
-
|
|
1040
|
-
system = platform.system()
|
|
1041
1182
|
|
|
1183
|
+
# 4) New window mode
|
|
1042
1184
|
custom_cmd = get_config_value('terminal_command')
|
|
1043
1185
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
bash_exe = find_bash_on_windows()
|
|
1057
|
-
if not bash_exe:
|
|
1058
|
-
raise Exception(format_error("Git Bash not found"))
|
|
1059
|
-
# Set up to use script approach below
|
|
1060
|
-
if shutil.which('wt'):
|
|
1061
|
-
# Windows Terminal available
|
|
1062
|
-
custom_cmd = f'wt {bash_exe} {{script}}'
|
|
1063
|
-
else:
|
|
1064
|
-
# Use cmd.exe with start command for visible window
|
|
1065
|
-
custom_cmd = f'cmd /c start "Claude Code" {bash_exe} {{script}}'
|
|
1066
|
-
|
|
1067
|
-
if custom_cmd and custom_cmd != 'None' and custom_cmd != 'null':
|
|
1068
|
-
# Check for {script} placeholder - the reliable way to handle complex commands
|
|
1069
|
-
if '{script}' in custom_cmd:
|
|
1070
|
-
# Create temp script file
|
|
1071
|
-
# Always use .sh extension for bash scripts
|
|
1072
|
-
script_ext = '.sh'
|
|
1073
|
-
# Use ~/.hcom/scripts/ instead of /tmp to avoid noexec issues
|
|
1074
|
-
script_file = str(hcom_path('scripts',
|
|
1075
|
-
f'launch_{os.getpid()}_{random.randint(1000,9999)}{script_ext}',
|
|
1076
|
-
ensure_parent=True))
|
|
1077
|
-
|
|
1078
|
-
# Create the bash script using helper
|
|
1079
|
-
create_bash_script(script_file, env, cwd, command_str, background)
|
|
1080
|
-
|
|
1081
|
-
# Replace {script} with the script path
|
|
1082
|
-
final_cmd = custom_cmd.replace('{script}', shlex.quote(script_file))
|
|
1083
|
-
|
|
1084
|
-
# Windows needs special flags
|
|
1085
|
-
if system == 'Windows':
|
|
1086
|
-
# Use Popen for non-blocking launch on Windows
|
|
1087
|
-
# Use shell=True for Windows to handle complex commands properly
|
|
1088
|
-
subprocess.Popen(final_cmd, shell=True)
|
|
1089
|
-
else:
|
|
1090
|
-
# TODO: Test if macOS/Linux will still work with Popen for parallel launches with custom terminals like windows
|
|
1091
|
-
result = subprocess.run(final_cmd, shell=True, capture_output=True)
|
|
1092
|
-
|
|
1093
|
-
# Schedule cleanup for all scripts (since we don't wait for completion)
|
|
1094
|
-
schedule_file_cleanup([script_file])
|
|
1095
|
-
|
|
1096
|
-
return True
|
|
1097
|
-
|
|
1098
|
-
# No {script} placeholder found
|
|
1099
|
-
else:
|
|
1100
|
-
print(format_error("Custom terminal command must use {script} placeholder",
|
|
1101
|
-
"Example: open -n -a kitty.app --args bash \"{script}\"'"),
|
|
1102
|
-
file=sys.stderr)
|
|
1103
|
-
return False
|
|
1104
|
-
|
|
1105
|
-
if system == 'Darwin': # macOS
|
|
1106
|
-
env_setup = build_env_string(env, "bash_export")
|
|
1107
|
-
# Include cd command if cwd is specified
|
|
1108
|
-
if cwd:
|
|
1109
|
-
full_cmd = f'cd {shlex.quote(cwd)}; {env_setup} {command_str}'
|
|
1110
|
-
else:
|
|
1111
|
-
full_cmd = f'{env_setup} {command_str}'
|
|
1112
|
-
|
|
1113
|
-
# Escape the command for AppleScript double-quoted string
|
|
1114
|
-
escaped = escape_for_platform(full_cmd, 'applescript')
|
|
1115
|
-
|
|
1116
|
-
script = f'tell app "Terminal" to do script "{escaped}"'
|
|
1117
|
-
subprocess.run(['osascript', '-e', script])
|
|
1118
|
-
return True
|
|
1119
|
-
|
|
1120
|
-
elif system == 'Linux':
|
|
1121
|
-
# Try Linux terminals first (works for both regular Linux and WSLg)
|
|
1122
|
-
terminals = [
|
|
1123
|
-
('gnome-terminal', ['gnome-terminal', '--', 'bash', '-c']),
|
|
1124
|
-
('konsole', ['konsole', '-e', 'bash', '-c']),
|
|
1125
|
-
('xterm', ['xterm', '-e', 'bash', '-c'])
|
|
1126
|
-
]
|
|
1127
|
-
|
|
1128
|
-
for term_name, term_cmd in terminals:
|
|
1129
|
-
if shutil.which(term_name):
|
|
1130
|
-
env_cmd = build_env_string(env)
|
|
1131
|
-
# Include cd command if cwd is specified
|
|
1132
|
-
if cwd:
|
|
1133
|
-
full_cmd = f'cd "{cwd}"; {env_cmd} {command_str}; exec bash'
|
|
1134
|
-
else:
|
|
1135
|
-
full_cmd = f'{env_cmd} {command_str}; exec bash'
|
|
1136
|
-
subprocess.run(term_cmd + [full_cmd])
|
|
1186
|
+
if not custom_cmd: # No string sentinel checks
|
|
1187
|
+
if is_termux():
|
|
1188
|
+
# Keep Termux as special case
|
|
1189
|
+
am_cmd = [
|
|
1190
|
+
'am', 'startservice', '--user', '0',
|
|
1191
|
+
'-n', 'com.termux/com.termux.app.RunCommandService',
|
|
1192
|
+
'-a', 'com.termux.RUN_COMMAND',
|
|
1193
|
+
'--es', 'com.termux.RUN_COMMAND_PATH', script_file,
|
|
1194
|
+
'--ez', 'com.termux.RUN_COMMAND_BACKGROUND', 'false'
|
|
1195
|
+
]
|
|
1196
|
+
try:
|
|
1197
|
+
subprocess.run(am_cmd, check=False)
|
|
1137
1198
|
return True
|
|
1199
|
+
except Exception as e:
|
|
1200
|
+
print(format_error(f"Failed to launch Termux: {e}"), file=sys.stderr)
|
|
1201
|
+
return False
|
|
1138
1202
|
|
|
1139
|
-
#
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1203
|
+
# Unified platform handling via helpers
|
|
1204
|
+
system = platform.system()
|
|
1205
|
+
terminal_getter = PLATFORM_TERMINAL_GETTERS.get(system)
|
|
1206
|
+
if not terminal_getter:
|
|
1207
|
+
raise Exception(format_error(f"Unsupported platform: {system}"))
|
|
1208
|
+
|
|
1209
|
+
custom_cmd = terminal_getter()
|
|
1210
|
+
if not custom_cmd: # e.g., Linux with no terminals
|
|
1211
|
+
raise Exception(format_error("No supported terminal emulator found",
|
|
1212
|
+
"Install gnome-terminal, konsole, or xterm"))
|
|
1213
|
+
|
|
1214
|
+
# Type-based dispatch for execution
|
|
1215
|
+
if isinstance(custom_cmd, list):
|
|
1216
|
+
# Our argv commands - safe execution without shell
|
|
1217
|
+
final_argv = [arg.replace('{script}', script_file) for arg in custom_cmd]
|
|
1218
|
+
try:
|
|
1219
|
+
if platform.system() == 'Windows':
|
|
1220
|
+
# Windows needs non-blocking for parallel launches
|
|
1221
|
+
subprocess.Popen(final_argv)
|
|
1222
|
+
return True # Popen is non-blocking, can't check success
|
|
1151
1223
|
else:
|
|
1152
|
-
subprocess.run(
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1224
|
+
result = subprocess.run(final_argv)
|
|
1225
|
+
if result.returncode != 0:
|
|
1226
|
+
return False
|
|
1227
|
+
return True
|
|
1228
|
+
except Exception as e:
|
|
1229
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
1230
|
+
return False
|
|
1160
1231
|
else:
|
|
1161
|
-
#
|
|
1162
|
-
|
|
1232
|
+
# User-provided string commands - parse safely without shell=True
|
|
1233
|
+
try:
|
|
1234
|
+
final_argv = _parse_terminal_command(custom_cmd, script_file)
|
|
1235
|
+
except ValueError as e:
|
|
1236
|
+
print(str(e), file=sys.stderr)
|
|
1237
|
+
return False
|
|
1238
|
+
|
|
1239
|
+
try:
|
|
1240
|
+
if platform.system() == 'Windows':
|
|
1241
|
+
# Windows needs non-blocking for parallel launches
|
|
1242
|
+
subprocess.Popen(final_argv)
|
|
1243
|
+
return True # Popen is non-blocking, can't check success
|
|
1244
|
+
else:
|
|
1245
|
+
result = subprocess.run(final_argv)
|
|
1246
|
+
if result.returncode != 0:
|
|
1247
|
+
return False
|
|
1248
|
+
return True
|
|
1249
|
+
except Exception as e:
|
|
1250
|
+
print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
|
|
1251
|
+
return False
|
|
1163
1252
|
|
|
1164
1253
|
def setup_hooks():
|
|
1165
1254
|
"""Set up Claude hooks in current directory"""
|
|
@@ -1598,12 +1687,7 @@ def show_instances_by_directory():
|
|
|
1598
1687
|
last_tool_name = pos_data.get("last_tool_name", "unknown")
|
|
1599
1688
|
last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
|
|
1600
1689
|
|
|
1601
|
-
|
|
1602
|
-
session_ids = pos_data.get("session_ids", [])
|
|
1603
|
-
sid = session_ids[-1] if session_ids else "" # Show most recent session
|
|
1604
|
-
session_info = f" | {sid}" if sid else ""
|
|
1605
|
-
|
|
1606
|
-
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{session_info}{RESET}")
|
|
1690
|
+
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{RESET}")
|
|
1607
1691
|
print()
|
|
1608
1692
|
else:
|
|
1609
1693
|
print(f" {DIM}Error reading instance data{RESET}")
|
|
@@ -1614,12 +1698,8 @@ def alt_screen_detailed_status_and_input():
|
|
|
1614
1698
|
|
|
1615
1699
|
try:
|
|
1616
1700
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
1617
|
-
print(f"{BOLD}
|
|
1618
|
-
print(f"{
|
|
1619
|
-
print(f"{FG_CYAN} HCOM: GLOBAL CHAT{RESET}")
|
|
1620
|
-
print(f"{DIM} LOG FILE: {hcom_path(LOG_FILE)}{RESET}")
|
|
1621
|
-
print(f"{DIM} UPDATED: {timestamp}{RESET}")
|
|
1622
|
-
print(f"{BOLD}{'-' * 70}{RESET}")
|
|
1701
|
+
print(f"{BOLD}HCOM{RESET} STATUS {DIM}- UPDATED: {timestamp}{RESET}")
|
|
1702
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1623
1703
|
print()
|
|
1624
1704
|
|
|
1625
1705
|
show_instances_by_directory()
|
|
@@ -1630,11 +1710,11 @@ def alt_screen_detailed_status_and_input():
|
|
|
1630
1710
|
show_recent_activity_alt_screen()
|
|
1631
1711
|
|
|
1632
1712
|
print()
|
|
1633
|
-
print(f"{
|
|
1634
|
-
print(f"{FG_GREEN}
|
|
1713
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1714
|
+
print(f"{FG_GREEN} Press Enter to send message (empty to cancel):{RESET}")
|
|
1635
1715
|
message = input(f"{FG_CYAN} > {RESET}")
|
|
1636
|
-
|
|
1637
|
-
print(f"{
|
|
1716
|
+
|
|
1717
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1638
1718
|
|
|
1639
1719
|
finally:
|
|
1640
1720
|
sys.stdout.write("\033[?1049l")
|
|
@@ -1817,15 +1897,10 @@ def show_main_screen_header():
|
|
|
1817
1897
|
all_messages = []
|
|
1818
1898
|
if log_file.exists():
|
|
1819
1899
|
all_messages = parse_log_messages(log_file)
|
|
1820
|
-
message_count = len(all_messages)
|
|
1821
|
-
|
|
1822
|
-
print(f"\n{BOLD}{'='*50}{RESET}")
|
|
1823
|
-
print(f" {FG_CYAN}HCOM: global chat{RESET}")
|
|
1900
|
+
# message_count = len(all_messages)
|
|
1824
1901
|
|
|
1825
|
-
|
|
1826
|
-
print(f"
|
|
1827
|
-
print(f" {DIM}LOGS: {log_file} ({message_count} messages){RESET}")
|
|
1828
|
-
print(f"{BOLD}{'='*50}{RESET}\n")
|
|
1902
|
+
print(f"{BOLD}HCOM{RESET} LOGS")
|
|
1903
|
+
print(f"{DIM}{'─'*40}{RESET}\n")
|
|
1829
1904
|
|
|
1830
1905
|
return all_messages
|
|
1831
1906
|
|
|
@@ -1915,7 +1990,7 @@ Key settings (full list in config.json):
|
|
|
1915
1990
|
env_overrides: "custom environment variables for instances"
|
|
1916
1991
|
|
|
1917
1992
|
Temporary environment overrides for any setting (all caps & append HCOM_):
|
|
1918
|
-
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages
|
|
1993
|
+
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages received by instance
|
|
1919
1994
|
export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
|
|
1920
1995
|
|
|
1921
1996
|
EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
|
|
@@ -1993,8 +2068,6 @@ def cmd_open(*args):
|
|
|
1993
2068
|
launched = 0
|
|
1994
2069
|
initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
|
|
1995
2070
|
|
|
1996
|
-
temp_files_to_cleanup = []
|
|
1997
|
-
|
|
1998
2071
|
for idx, instance_type in enumerate(instances):
|
|
1999
2072
|
instance_env = base_env.copy()
|
|
2000
2073
|
|
|
@@ -2020,6 +2093,8 @@ def cmd_open(*args):
|
|
|
2020
2093
|
# Agent instance
|
|
2021
2094
|
try:
|
|
2022
2095
|
agent_content, agent_config = resolve_agent(instance_type)
|
|
2096
|
+
# Mark this as a subagent instance for SessionStart hook
|
|
2097
|
+
instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
|
|
2023
2098
|
# Prepend agent instance awareness to system prompt
|
|
2024
2099
|
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
2025
2100
|
agent_content = agent_prefix + agent_content
|
|
@@ -2033,8 +2108,7 @@ def cmd_open(*args):
|
|
|
2033
2108
|
model=agent_model,
|
|
2034
2109
|
tools=agent_tools
|
|
2035
2110
|
)
|
|
2036
|
-
|
|
2037
|
-
temp_files_to_cleanup.append(temp_file)
|
|
2111
|
+
# Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
|
|
2038
2112
|
except (FileNotFoundError, ValueError) as e:
|
|
2039
2113
|
print(str(e), file=sys.stderr)
|
|
2040
2114
|
continue
|
|
@@ -2046,31 +2120,38 @@ def cmd_open(*args):
|
|
|
2046
2120
|
print(f"Background instance launched, log: {log_file}")
|
|
2047
2121
|
launched += 1
|
|
2048
2122
|
else:
|
|
2049
|
-
launch_terminal(claude_cmd, instance_env, cwd=os.getcwd())
|
|
2050
|
-
|
|
2123
|
+
if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
|
|
2124
|
+
launched += 1
|
|
2051
2125
|
except Exception as e:
|
|
2052
2126
|
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
2053
2127
|
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2128
|
+
requested = len(instances)
|
|
2129
|
+
failed = requested - launched
|
|
2130
|
+
|
|
2058
2131
|
if launched == 0:
|
|
2059
|
-
print(format_error("No instances launched"), file=sys.stderr)
|
|
2132
|
+
print(format_error(f"No instances launched (0/{requested})"), file=sys.stderr)
|
|
2060
2133
|
return 1
|
|
2061
|
-
|
|
2062
|
-
#
|
|
2063
|
-
|
|
2134
|
+
|
|
2135
|
+
# Show results
|
|
2136
|
+
if failed > 0:
|
|
2137
|
+
print(f"Launched {launched}/{requested} Claude instance{'s' if requested != 1 else ''} ({failed} failed)")
|
|
2138
|
+
else:
|
|
2139
|
+
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
2064
2140
|
|
|
2065
2141
|
# Auto-launch watch dashboard if configured and conditions are met
|
|
2066
2142
|
terminal_mode = get_config_value('terminal_mode')
|
|
2067
2143
|
auto_watch = get_config_value('auto_watch', True)
|
|
2068
2144
|
|
|
2069
|
-
|
|
2145
|
+
# Only auto-watch if ALL instances launched successfully
|
|
2146
|
+
if terminal_mode == 'new_window' and auto_watch and failed == 0 and is_interactive():
|
|
2070
2147
|
# Show tips first if needed
|
|
2071
2148
|
if prefix:
|
|
2072
2149
|
print(f"\n • Send to {prefix} team: hcom send '@{prefix} message'")
|
|
2073
2150
|
|
|
2151
|
+
# Clear transition message
|
|
2152
|
+
print("\nOpening hcom watch...")
|
|
2153
|
+
time.sleep(2) # Brief pause so user sees the message
|
|
2154
|
+
|
|
2074
2155
|
# Launch interactive watch dashboard in current terminal
|
|
2075
2156
|
return cmd_watch()
|
|
2076
2157
|
else:
|
|
@@ -2081,7 +2162,7 @@ def cmd_open(*args):
|
|
|
2081
2162
|
tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
|
|
2082
2163
|
|
|
2083
2164
|
if tips:
|
|
2084
|
-
print("\n" + "\n".join(f" • {tip}" for tip in tips))
|
|
2165
|
+
print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
|
|
2085
2166
|
|
|
2086
2167
|
# Show cli_hints if configured (non-interactive mode)
|
|
2087
2168
|
if not is_interactive():
|
|
@@ -2102,7 +2183,7 @@ def cmd_watch(*args):
|
|
|
2102
2183
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2103
2184
|
|
|
2104
2185
|
if not log_file.exists() and not instances_dir.exists():
|
|
2105
|
-
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2186
|
+
print(format_error("No conversation log found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2106
2187
|
return 1
|
|
2107
2188
|
|
|
2108
2189
|
# Parse arguments
|
|
@@ -2249,8 +2330,8 @@ def cmd_watch(*args):
|
|
|
2249
2330
|
all_messages = show_main_screen_header()
|
|
2250
2331
|
|
|
2251
2332
|
show_recent_messages(all_messages, limit=5)
|
|
2252
|
-
print(f"\n{DIM}
|
|
2253
|
-
|
|
2333
|
+
print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
|
|
2334
|
+
|
|
2254
2335
|
# Print newline to ensure status starts on its own line
|
|
2255
2336
|
print()
|
|
2256
2337
|
|
|
@@ -2302,7 +2383,8 @@ def cmd_watch(*args):
|
|
|
2302
2383
|
|
|
2303
2384
|
all_messages = show_main_screen_header()
|
|
2304
2385
|
show_recent_messages(all_messages)
|
|
2305
|
-
print(f"\n{DIM}
|
|
2386
|
+
print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
|
|
2387
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
2306
2388
|
|
|
2307
2389
|
if log_file.exists():
|
|
2308
2390
|
last_pos = log_file.stat().st_size
|
|
@@ -2338,6 +2420,14 @@ def cmd_clear():
|
|
|
2338
2420
|
if deleted_count > 0:
|
|
2339
2421
|
print(f"Cleaned up {deleted_count} temp files")
|
|
2340
2422
|
|
|
2423
|
+
# Clean up old script files (older than 24 hours)
|
|
2424
|
+
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
2425
|
+
if scripts_dir.exists():
|
|
2426
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2427
|
+
script_count = sum(1 for f in scripts_dir.glob('*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
|
|
2428
|
+
if script_count > 0:
|
|
2429
|
+
print(f"Cleaned up {script_count} old script files")
|
|
2430
|
+
|
|
2341
2431
|
# Check if hcom files exist
|
|
2342
2432
|
if not log_file.exists() and not instances_dir.exists():
|
|
2343
2433
|
print("No hcom conversation to clear")
|
|
@@ -2383,7 +2473,7 @@ def cmd_clear():
|
|
|
2383
2473
|
|
|
2384
2474
|
log_file.touch()
|
|
2385
2475
|
clear_all_positions()
|
|
2386
|
-
|
|
2476
|
+
|
|
2387
2477
|
if archived:
|
|
2388
2478
|
print(f"Archived to archive/session-{timestamp}/")
|
|
2389
2479
|
print("Started fresh hcom conversation log")
|
|
@@ -2594,7 +2684,7 @@ def cmd_send(message):
|
|
|
2594
2684
|
print(format_error("Failed to send message"), file=sys.stderr)
|
|
2595
2685
|
return 1
|
|
2596
2686
|
|
|
2597
|
-
# ==================== Hook
|
|
2687
|
+
# ==================== Hook Helpers ====================
|
|
2598
2688
|
|
|
2599
2689
|
def format_hook_messages(messages, instance_name):
|
|
2600
2690
|
"""Format messages for hook feedback"""
|
|
@@ -3046,6 +3136,11 @@ def handle_sessionstart(hook_data, instance_name, updates):
|
|
|
3046
3136
|
# Always show base help text
|
|
3047
3137
|
help_text = "[Welcome! HCOM chat active. Send messages: echo 'HCOM_SEND:your message']"
|
|
3048
3138
|
|
|
3139
|
+
# Add subagent type if this is a named agent
|
|
3140
|
+
subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
|
|
3141
|
+
if subagent_type:
|
|
3142
|
+
help_text += f" [Subagent: {subagent_type}]"
|
|
3143
|
+
|
|
3049
3144
|
# Add first use text only on startup
|
|
3050
3145
|
if source == 'startup':
|
|
3051
3146
|
first_use_text = get_config_value('first_use_text', '')
|