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/__main__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- hcom 0.2.1
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
- # ==================== Configuration ====================
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
- return hcom_path(INSTANCES_DIR, f"{instance_name}.json")
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': # Convert string to boolean
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 = shell_quote(python_path)
400
- escaped_script = shell_quote(script_path)
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 and Helper Functions ====================
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('Team name cannot contain pipe characters'))
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 after stripping YAML frontmatter, config dict)
615
+
616
+ Returns tuple: (content without YAML frontmatter, config dict)
597
617
  """
598
- for base_path in [Path.cwd(), Path.home()]:
599
- agent_path = base_path / '.claude/agents' / f'{name}.md'
600
- if agent_path.exists():
601
- content = read_file_with_retry(
602
- agent_path,
603
- lambda f: f.read(),
604
- default=None
605
- )
606
- if content is None:
607
- continue # Skip to next base_path if read failed
608
- config = extract_agent_config(content)
609
- stripped = strip_frontmatter(content)
610
- if not stripped.strip():
611
- raise ValueError(format_error(f"Agent '{name}' has empty content", 'Check the agent file contains a system prompt'))
612
- return stripped, config
613
-
614
- raise FileNotFoundError(format_error(f'Agent not found: {name}', 'Check available agents or create the agent file'))
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=tempfile.gettempdir())
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(shell_quote(initial_prompt))
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
- command_str = command_str.replace('claude ', f'{claude_path} ', 1)
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
- def schedule_file_cleanup(files, delay=5):
912
- """Schedule cleanup of temporary files after delay"""
913
- if not files:
914
- return
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
- def cleanup():
917
- time.sleep(delay)
918
- for file_path in files:
919
- try:
920
- os.unlink(file_path)
921
- except (OSError, FileNotFoundError):
922
- pass # File already deleted or inaccessible
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
- thread = threading.Thread(target=cleanup, daemon=True)
925
- thread.start()
1090
+ return replaced
926
1091
 
927
- def launch_terminal(command, env, config=None, cwd=None, background=False):
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: Either a string command or list of command parts
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
- # Background mode implementation
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: Use bash script approach for proper $(cat ...) support
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
- # Create script file for background process
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
- stdin=subprocess.DEVNULL,
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: use start_new_session for proper detachment
1131
+ # Unix(Mac/Linux/Termux): detached bash execution with Python-piped logs
987
1132
  process = subprocess.Popen(
988
- command_str,
989
- shell=True,
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
- stderr=subprocess.STDOUT,
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
- # Check for immediate failures
1143
+
1144
+ # Health check
1002
1145
  time.sleep(0.2)
1003
1146
  if process.poll() is not None:
1004
- # Process already exited
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
- env_str = build_env_string(env)
1021
- print(f"{env_str} {command_str}")
1022
- return True
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
- elif terminal_mode == 'same_terminal':
1025
- print(f"Launching Claude in current terminal...")
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
- # Run with bash -c to execute in current terminal
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
- # Unix/Linux/Mac: shell=True works fine
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
- # Check for macOS Terminal.app 1024 char TTY limit
1045
- if not custom_cmd and system == 'Darwin':
1046
- full_cmd = build_env_string(env, "bash_export") + command_str
1047
- if cwd:
1048
- full_cmd = f'cd {shlex.quote(cwd)}; {full_cmd}'
1049
- if len(full_cmd) > 1000: # Switch before 1024 limit
1050
- # Force script path by setting custom_cmd
1051
- custom_cmd = 'osascript -e \'tell app "Terminal" to do script "{script}"\''
1052
-
1053
- # Windows also needs script approach - treat it like custom terminal
1054
- if system == 'Windows' and not custom_cmd:
1055
- # Windows always uses script approach with bash
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
- # No Linux terminals found - check if WSL and can use Windows Terminal as fallback
1140
- if is_wsl() and shutil.which('cmd.exe'):
1141
- # WSL fallback: Use Windows Terminal through cmd.exe
1142
- script_file = str(hcom_path('scripts',
1143
- f'wsl_launch_{os.getpid()}_{random.randint(1000,9999)}.sh',
1144
- ensure_parent=True))
1145
-
1146
- create_bash_script(script_file, env, cwd, command_str, background=False)
1147
-
1148
- # Use Windows Terminal if available, otherwise cmd.exe with start
1149
- if shutil.which('wt.exe'):
1150
- subprocess.run(['cmd.exe', '/c', 'start', 'wt.exe', 'bash', script_file])
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(['cmd.exe', '/c', 'start', 'bash', script_file])
1153
-
1154
- # Schedule cleanup
1155
- schedule_file_cleanup([script_file], delay=5)
1156
- return True
1157
-
1158
- raise Exception(format_error("No supported terminal emulator found", "Install gnome-terminal, konsole, or xterm"))
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
- # Windows is now handled by the custom_cmd logic above
1162
- raise Exception(format_error(f"Unsupported platform: {system}", "Supported platforms: macOS, Linux, Windows"))
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
- # Get session IDs (already migrated to array format)
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} HCOM DETAILED STATUS{RESET}")
1618
- print(f"{BOLD}{'=' * 70}{RESET}")
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"{BOLD}{'-' * 70}{RESET}")
1634
- print(f"{FG_GREEN} Type message and press Enter to send (empty to cancel):{RESET}")
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"{BOLD}{'=' * 70}{RESET}")
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
- status_line = get_status_summary()
1826
- print(f" {BOLD}INSTANCES:{RESET} {status_line}")
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 recieved by instance
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
- if temp_file:
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
- launched += 1
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
- # Clean up temp files after a delay (let terminals read them first)
2055
- if temp_files_to_cleanup:
2056
- schedule_file_cleanup(temp_files_to_cleanup)
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
- # Success message
2063
- print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
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
- if terminal_mode == 'new_window' and auto_watch and launched > 0 and is_interactive():
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}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
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}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
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 Functions ====================
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', '')