hcom 0.2.1__py3-none-any.whl → 0.2.2__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 +319 -272
- {hcom-0.2.1.dist-info → hcom-0.2.2.dist-info}/METADATA +93 -67
- hcom-0.2.2.dist-info/RECORD +7 -0
- hcom-0.2.1.dist-info/RECORD +0 -7
- {hcom-0.2.1.dist-info → hcom-0.2.2.dist-info}/WHEEL +0 -0
- {hcom-0.2.1.dist-info → hcom-0.2.2.dist-info}/entry_points.txt +0 -0
- {hcom-0.2.1.dist-info → hcom-0.2.2.dist-info}/top_level.txt +0 -0
hcom/__init__.py
CHANGED
hcom/__main__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
hcom 0.2.
|
|
3
|
+
hcom 0.2.2
|
|
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
|
|
|
@@ -165,6 +173,7 @@ HOOK_SETTINGS = {
|
|
|
165
173
|
LOG_FILE = "hcom.log"
|
|
166
174
|
INSTANCES_DIR = "instances"
|
|
167
175
|
LOGS_DIR = "logs"
|
|
176
|
+
SCRIPTS_DIR = "scripts"
|
|
168
177
|
CONFIG_FILE = "config.json"
|
|
169
178
|
ARCHIVE_DIR = "archive"
|
|
170
179
|
|
|
@@ -396,8 +405,8 @@ def get_hook_command():
|
|
|
396
405
|
return f'IF "%HCOM_ACTIVE%"=="1" {python_path} {script_path}', {}
|
|
397
406
|
elif ' ' in python_path or ' ' in script_path:
|
|
398
407
|
# Unix with spaces: use conditional check
|
|
399
|
-
escaped_python =
|
|
400
|
-
escaped_script =
|
|
408
|
+
escaped_python = shlex.quote(python_path)
|
|
409
|
+
escaped_script = shlex.quote(script_path)
|
|
401
410
|
return f'[ "${{HCOM_ACTIVE}}" = "1" ] && {escaped_python} {escaped_script} || true', {}
|
|
402
411
|
else:
|
|
403
412
|
# Unix clean paths: use environment variable
|
|
@@ -526,7 +535,7 @@ def parse_open_args(args):
|
|
|
526
535
|
raise ValueError(format_error('--prefix requires an argument'))
|
|
527
536
|
prefix = args[i + 1]
|
|
528
537
|
if '|' in prefix:
|
|
529
|
-
raise ValueError(format_error('
|
|
538
|
+
raise ValueError(format_error('Prefix cannot contain pipe characters'))
|
|
530
539
|
i += 2
|
|
531
540
|
elif arg == '--claude-args':
|
|
532
541
|
# Next argument contains claude args as a string
|
|
@@ -608,10 +617,10 @@ def resolve_agent(name):
|
|
|
608
617
|
config = extract_agent_config(content)
|
|
609
618
|
stripped = strip_frontmatter(content)
|
|
610
619
|
if not stripped.strip():
|
|
611
|
-
raise ValueError(format_error(f"Agent '{name}' has empty content", 'Check the agent file
|
|
620
|
+
raise ValueError(format_error(f"Agent '{name}' has empty content", 'Check the agent file is a valid format and contains text'))
|
|
612
621
|
return stripped, config
|
|
613
622
|
|
|
614
|
-
raise FileNotFoundError(format_error(f'Agent not found
|
|
623
|
+
raise FileNotFoundError(format_error(f'Agent "{name}" not found in project or user .claude/agents/ folder', 'Check available agents or create the agent file'))
|
|
615
624
|
|
|
616
625
|
def strip_frontmatter(content):
|
|
617
626
|
"""Strip YAML frontmatter from agent file"""
|
|
@@ -766,7 +775,6 @@ def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
|
766
775
|
|
|
767
776
|
def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat", model=None, tools=None):
|
|
768
777
|
"""Build Claude command with proper argument handling
|
|
769
|
-
|
|
770
778
|
Returns tuple: (command_string, temp_file_path_or_none)
|
|
771
779
|
For agent content, writes to temp file and uses cat to read it.
|
|
772
780
|
"""
|
|
@@ -789,8 +797,11 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
789
797
|
cmd_parts.append(shlex.quote(arg))
|
|
790
798
|
|
|
791
799
|
if agent_content:
|
|
800
|
+
# Create agent files in scripts directory for unified cleanup
|
|
801
|
+
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
802
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
792
803
|
temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
|
|
793
|
-
prefix='hcom_agent_', dir=
|
|
804
|
+
prefix='hcom_agent_', dir=str(scripts_dir))
|
|
794
805
|
temp_file.write(agent_content)
|
|
795
806
|
temp_file.close()
|
|
796
807
|
temp_file_path = temp_file.name
|
|
@@ -807,34 +818,18 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
|
|
|
807
818
|
cmd_parts.append('--')
|
|
808
819
|
|
|
809
820
|
# Quote initial prompt normally
|
|
810
|
-
cmd_parts.append(
|
|
821
|
+
cmd_parts.append(shlex.quote(initial_prompt))
|
|
811
822
|
|
|
812
823
|
return ' '.join(cmd_parts), temp_file_path
|
|
813
824
|
|
|
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
825
|
def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
837
|
-
"""Create a bash script for terminal launch
|
|
826
|
+
"""Create a bash script for terminal launch
|
|
827
|
+
Scripts provide uniform execution across all platforms/terminals.
|
|
828
|
+
Cleanup behavior:
|
|
829
|
+
- Normal scripts: append 'rm -f' command for self-deletion
|
|
830
|
+
- Background scripts: persist until `hcom clear` housekeeping (24 hours)
|
|
831
|
+
- Agent scripts: treated like background (contain 'hcom_agent_')
|
|
832
|
+
"""
|
|
838
833
|
try:
|
|
839
834
|
# Ensure parent directory exists
|
|
840
835
|
script_path = Path(script_file)
|
|
@@ -845,15 +840,52 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
|
845
840
|
with open(script_file, 'w', encoding='utf-8') as f:
|
|
846
841
|
f.write('#!/bin/bash\n')
|
|
847
842
|
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
843
|
|
|
852
|
-
# On Windows, let bash resolve claude from PATH (Windows paths don't work in bash)
|
|
853
844
|
if platform.system() != 'Windows':
|
|
845
|
+
# 1. Discover paths once
|
|
854
846
|
claude_path = shutil.which('claude')
|
|
847
|
+
node_path = shutil.which('node')
|
|
848
|
+
|
|
849
|
+
# 2. Add to PATH for minimal environments
|
|
850
|
+
paths_to_add = []
|
|
851
|
+
for p in [node_path, claude_path]:
|
|
852
|
+
if p:
|
|
853
|
+
dir_path = os.path.dirname(os.path.realpath(p))
|
|
854
|
+
if dir_path not in paths_to_add:
|
|
855
|
+
paths_to_add.append(dir_path)
|
|
856
|
+
|
|
857
|
+
if paths_to_add:
|
|
858
|
+
path_addition = ':'.join(paths_to_add)
|
|
859
|
+
f.write(f'export PATH="{path_addition}:$PATH"\n')
|
|
860
|
+
elif not claude_path:
|
|
861
|
+
# Warning for debugging
|
|
862
|
+
print("Warning: Could not locate 'claude' in PATH", file=sys.stderr)
|
|
863
|
+
|
|
864
|
+
# 3. Write environment variables
|
|
865
|
+
f.write(build_env_string(env, "bash_export") + '\n')
|
|
866
|
+
|
|
867
|
+
if cwd:
|
|
868
|
+
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
869
|
+
|
|
870
|
+
# 4. Platform-specific command modifications
|
|
855
871
|
if claude_path:
|
|
856
|
-
|
|
872
|
+
if is_termux():
|
|
873
|
+
# Termux: explicit node to bypass shebang issues
|
|
874
|
+
final_node = node_path or '/data/data/com.termux/files/usr/bin/node'
|
|
875
|
+
# Quote paths for safety
|
|
876
|
+
command_str = command_str.replace(
|
|
877
|
+
'claude ',
|
|
878
|
+
f'{shlex.quote(final_node)} {shlex.quote(claude_path)} ',
|
|
879
|
+
1
|
|
880
|
+
)
|
|
881
|
+
else:
|
|
882
|
+
# Mac/Linux: use full path (PATH now has node if needed)
|
|
883
|
+
command_str = command_str.replace('claude ', f'{shlex.quote(claude_path)} ', 1)
|
|
884
|
+
else:
|
|
885
|
+
# Windows: no PATH modification needed
|
|
886
|
+
f.write(build_env_string(env, "bash_export") + '\n')
|
|
887
|
+
if cwd:
|
|
888
|
+
f.write(f'cd {shlex.quote(cwd)}\n')
|
|
857
889
|
|
|
858
890
|
f.write(f'{command_str}\n')
|
|
859
891
|
|
|
@@ -908,258 +940,266 @@ def find_bash_on_windows():
|
|
|
908
940
|
|
|
909
941
|
return None
|
|
910
942
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
943
|
+
# New helper functions for platform-specific terminal launching
|
|
944
|
+
def get_macos_terminal_argv():
|
|
945
|
+
"""Return macOS Terminal.app launch command as argv list."""
|
|
946
|
+
return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
|
|
947
|
+
|
|
948
|
+
def get_windows_terminal_argv():
|
|
949
|
+
"""Return Windows terminal launcher as argv list."""
|
|
950
|
+
bash_exe = find_bash_on_windows()
|
|
951
|
+
if not bash_exe:
|
|
952
|
+
raise Exception(format_error("Git Bash not found"))
|
|
953
|
+
|
|
954
|
+
if shutil.which('wt'):
|
|
955
|
+
return ['wt', bash_exe, '{script}']
|
|
956
|
+
return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
|
|
957
|
+
|
|
958
|
+
def get_linux_terminal_argv():
|
|
959
|
+
"""Return first available Linux terminal as argv list."""
|
|
960
|
+
terminals = [
|
|
961
|
+
('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
|
|
962
|
+
('konsole', ['konsole', '-e', 'bash', '{script}']),
|
|
963
|
+
('xterm', ['xterm', '-e', 'bash', '{script}']),
|
|
964
|
+
]
|
|
965
|
+
for term_name, argv_template in terminals:
|
|
966
|
+
if shutil.which(term_name):
|
|
967
|
+
return argv_template
|
|
915
968
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
969
|
+
# WSL fallback integrated here
|
|
970
|
+
if is_wsl() and shutil.which('cmd.exe'):
|
|
971
|
+
if shutil.which('wt.exe'):
|
|
972
|
+
return ['cmd.exe', '/c', 'start', 'wt.exe', 'bash', '{script}']
|
|
973
|
+
return ['cmd.exe', '/c', 'start', 'bash', '{script}']
|
|
974
|
+
|
|
975
|
+
return None
|
|
923
976
|
|
|
924
|
-
|
|
925
|
-
|
|
977
|
+
def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
|
|
978
|
+
"""Create hidden Windows process without console window."""
|
|
979
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
980
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
981
|
+
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
982
|
+
|
|
983
|
+
return subprocess.Popen(
|
|
984
|
+
argv,
|
|
985
|
+
env=env,
|
|
986
|
+
cwd=cwd,
|
|
987
|
+
stdin=subprocess.DEVNULL,
|
|
988
|
+
stdout=stdout,
|
|
989
|
+
stderr=subprocess.STDOUT,
|
|
990
|
+
startupinfo=startupinfo,
|
|
991
|
+
creationflags=CREATE_NO_WINDOW
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# Platform dispatch map
|
|
995
|
+
PLATFORM_TERMINAL_GETTERS = {
|
|
996
|
+
'Darwin': get_macos_terminal_argv,
|
|
997
|
+
'Windows': get_windows_terminal_argv,
|
|
998
|
+
'Linux': get_linux_terminal_argv,
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
def _parse_terminal_command(template, script_file):
|
|
1002
|
+
"""Parse terminal command template safely to prevent shell injection.
|
|
1003
|
+
Parses the template FIRST, then replaces {script} placeholder in the
|
|
1004
|
+
parsed tokens. This avoids shell injection and handles paths with spaces.
|
|
1005
|
+
Args:
|
|
1006
|
+
template: Terminal command template with {script} placeholder
|
|
1007
|
+
script_file: Path to script file to substitute
|
|
1008
|
+
Returns:
|
|
1009
|
+
list: Parsed command as argv array
|
|
1010
|
+
Raises:
|
|
1011
|
+
ValueError: If template is invalid or missing {script} placeholder
|
|
1012
|
+
"""
|
|
1013
|
+
if '{script}' not in template:
|
|
1014
|
+
raise ValueError(format_error("Custom terminal command must include {script} placeholder",
|
|
1015
|
+
'Example: open -n -a kitty.app --args bash "{script}"'))
|
|
1016
|
+
|
|
1017
|
+
try:
|
|
1018
|
+
parts = shlex.split(template)
|
|
1019
|
+
except ValueError as e:
|
|
1020
|
+
raise ValueError(format_error(f"Invalid terminal command syntax: {e}",
|
|
1021
|
+
"Check for unmatched quotes or invalid shell syntax"))
|
|
1022
|
+
|
|
1023
|
+
# Replace {script} in parsed tokens
|
|
1024
|
+
replaced = []
|
|
1025
|
+
placeholder_found = False
|
|
1026
|
+
for part in parts:
|
|
1027
|
+
if '{script}' in part:
|
|
1028
|
+
replaced.append(part.replace('{script}', script_file))
|
|
1029
|
+
placeholder_found = True
|
|
1030
|
+
else:
|
|
1031
|
+
replaced.append(part)
|
|
1032
|
+
|
|
1033
|
+
if not placeholder_found:
|
|
1034
|
+
raise ValueError(format_error("{script} placeholder not found after parsing",
|
|
1035
|
+
"Ensure {script} is not inside environment variables"))
|
|
1036
|
+
|
|
1037
|
+
return replaced
|
|
926
1038
|
|
|
927
1039
|
def launch_terminal(command, env, config=None, cwd=None, background=False):
|
|
928
|
-
"""Launch terminal with command
|
|
1040
|
+
"""Launch terminal with command using unified script-first approach
|
|
929
1041
|
Args:
|
|
930
|
-
command:
|
|
1042
|
+
command: Command string from build_claude_command
|
|
931
1043
|
env: Environment variables to set
|
|
932
1044
|
config: Configuration dict
|
|
933
1045
|
cwd: Working directory
|
|
934
1046
|
background: Launch as background process
|
|
935
1047
|
"""
|
|
936
|
-
|
|
937
1048
|
if config is None:
|
|
938
1049
|
config = get_cached_config()
|
|
939
1050
|
|
|
940
1051
|
env_vars = os.environ.copy()
|
|
941
1052
|
env_vars.update(env)
|
|
942
|
-
|
|
943
|
-
# Command should now always be a string from build_claude_command
|
|
944
1053
|
command_str = command
|
|
945
|
-
|
|
946
|
-
#
|
|
1054
|
+
|
|
1055
|
+
# 1) Always create a script
|
|
1056
|
+
script_file = str(hcom_path(SCRIPTS_DIR,
|
|
1057
|
+
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh',
|
|
1058
|
+
ensure_parent=True))
|
|
1059
|
+
create_bash_script(script_file, env, cwd, command_str, background)
|
|
1060
|
+
|
|
1061
|
+
# 2) Background mode
|
|
947
1062
|
if background:
|
|
948
|
-
# Create log file for background instance
|
|
949
1063
|
logs_dir = hcom_path(LOGS_DIR)
|
|
950
1064
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
951
1065
|
log_file = logs_dir / env['HCOM_BACKGROUND']
|
|
952
1066
|
|
|
953
|
-
# Launch detached process
|
|
954
1067
|
try:
|
|
955
1068
|
with open(log_file, 'w', encoding='utf-8') as log_handle:
|
|
956
1069
|
if IS_WINDOWS:
|
|
957
|
-
# Windows:
|
|
1070
|
+
# Windows: hidden bash execution with Python-piped logs
|
|
958
1071
|
bash_exe = find_bash_on_windows()
|
|
959
1072
|
if not bash_exe:
|
|
960
1073
|
raise Exception("Git Bash not found")
|
|
961
1074
|
|
|
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(
|
|
1075
|
+
process = windows_hidden_popen(
|
|
976
1076
|
[bash_exe, script_file],
|
|
977
1077
|
env=env_vars,
|
|
978
1078
|
cwd=cwd,
|
|
979
|
-
|
|
980
|
-
stdout=log_handle,
|
|
981
|
-
stderr=subprocess.STDOUT,
|
|
982
|
-
startupinfo=startupinfo,
|
|
983
|
-
creationflags=CREATE_NO_WINDOW # Belt and suspenders approach
|
|
1079
|
+
stdout=log_handle
|
|
984
1080
|
)
|
|
985
1081
|
else:
|
|
986
|
-
# Unix:
|
|
1082
|
+
# Unix(Mac/Linux/Termux): detached bash execution with Python-piped logs
|
|
987
1083
|
process = subprocess.Popen(
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
env=env_vars,
|
|
991
|
-
cwd=cwd,
|
|
1084
|
+
['bash', script_file],
|
|
1085
|
+
env=env_vars, cwd=cwd,
|
|
992
1086
|
stdin=subprocess.DEVNULL,
|
|
993
|
-
stdout=log_handle,
|
|
994
|
-
|
|
995
|
-
start_new_session=True # detach from terminal session
|
|
1087
|
+
stdout=log_handle, stderr=subprocess.STDOUT,
|
|
1088
|
+
start_new_session=True
|
|
996
1089
|
)
|
|
1090
|
+
|
|
997
1091
|
except OSError as e:
|
|
998
1092
|
print(format_error(f"Failed to launch background instance: {e}"), file=sys.stderr)
|
|
999
1093
|
return None
|
|
1000
|
-
|
|
1001
|
-
#
|
|
1094
|
+
|
|
1095
|
+
# Health check
|
|
1002
1096
|
time.sleep(0.2)
|
|
1003
1097
|
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
|
-
)
|
|
1098
|
+
error_output = read_file_with_retry(log_file, lambda f: f.read()[:1000], default="")
|
|
1010
1099
|
print(format_error("Background instance failed immediately"), file=sys.stderr)
|
|
1011
1100
|
if error_output:
|
|
1012
1101
|
print(f" Output: {error_output}", file=sys.stderr)
|
|
1013
1102
|
return None
|
|
1014
|
-
|
|
1103
|
+
|
|
1015
1104
|
return str(log_file)
|
|
1016
1105
|
|
|
1106
|
+
# 3) Terminal modes
|
|
1017
1107
|
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
1018
1108
|
|
|
1019
1109
|
if terminal_mode == 'show_commands':
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1110
|
+
# Print script path and contents
|
|
1111
|
+
try:
|
|
1112
|
+
with open(script_file, 'r', encoding='utf-8') as f:
|
|
1113
|
+
script_content = f.read()
|
|
1114
|
+
print(f"# Script: {script_file}")
|
|
1115
|
+
print(script_content)
|
|
1116
|
+
os.unlink(script_file) # Clean up immediately
|
|
1117
|
+
return True
|
|
1118
|
+
except Exception as e:
|
|
1119
|
+
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
1120
|
+
return False
|
|
1023
1121
|
|
|
1024
|
-
|
|
1025
|
-
print(
|
|
1122
|
+
if terminal_mode == 'same_terminal':
|
|
1123
|
+
print("Launching Claude in current terminal...")
|
|
1026
1124
|
if IS_WINDOWS:
|
|
1027
|
-
# Windows: Use bash directly to support $(cat ...) syntax
|
|
1028
1125
|
bash_exe = find_bash_on_windows()
|
|
1029
1126
|
if not bash_exe:
|
|
1030
1127
|
print(format_error("Git Bash not found"), file=sys.stderr)
|
|
1031
1128
|
return False
|
|
1032
|
-
|
|
1033
|
-
result = subprocess.run([bash_exe, '-c', command_str], env=env_vars, cwd=cwd)
|
|
1129
|
+
result = subprocess.run([bash_exe, script_file], env=env_vars, cwd=cwd)
|
|
1034
1130
|
else:
|
|
1035
|
-
|
|
1036
|
-
result = subprocess.run(command_str, shell=True, env=env_vars, cwd=cwd)
|
|
1037
|
-
|
|
1131
|
+
result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
|
|
1038
1132
|
return result.returncode == 0
|
|
1039
|
-
|
|
1040
|
-
system = platform.system()
|
|
1041
1133
|
|
|
1134
|
+
# 4) New window mode
|
|
1042
1135
|
custom_cmd = get_config_value('terminal_command')
|
|
1043
1136
|
|
|
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])
|
|
1137
|
+
if not custom_cmd: # No string sentinel checks
|
|
1138
|
+
if is_termux():
|
|
1139
|
+
# Keep Termux as special case
|
|
1140
|
+
am_cmd = [
|
|
1141
|
+
'am', 'startservice', '--user', '0',
|
|
1142
|
+
'-n', 'com.termux/com.termux.app.RunCommandService',
|
|
1143
|
+
'-a', 'com.termux.RUN_COMMAND',
|
|
1144
|
+
'--es', 'com.termux.RUN_COMMAND_PATH', script_file,
|
|
1145
|
+
'--ez', 'com.termux.RUN_COMMAND_BACKGROUND', 'false'
|
|
1146
|
+
]
|
|
1147
|
+
try:
|
|
1148
|
+
subprocess.run(am_cmd, check=False)
|
|
1137
1149
|
return True
|
|
1150
|
+
except Exception as e:
|
|
1151
|
+
print(format_error(f"Failed to launch Termux: {e}"), file=sys.stderr)
|
|
1152
|
+
return False
|
|
1138
1153
|
|
|
1139
|
-
#
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1154
|
+
# Unified platform handling via helpers
|
|
1155
|
+
system = platform.system()
|
|
1156
|
+
terminal_getter = PLATFORM_TERMINAL_GETTERS.get(system)
|
|
1157
|
+
if not terminal_getter:
|
|
1158
|
+
raise Exception(format_error(f"Unsupported platform: {system}"))
|
|
1159
|
+
|
|
1160
|
+
custom_cmd = terminal_getter()
|
|
1161
|
+
if not custom_cmd: # e.g., Linux with no terminals
|
|
1162
|
+
raise Exception(format_error("No supported terminal emulator found",
|
|
1163
|
+
"Install gnome-terminal, konsole, or xterm"))
|
|
1164
|
+
|
|
1165
|
+
# Type-based dispatch for execution
|
|
1166
|
+
if isinstance(custom_cmd, list):
|
|
1167
|
+
# Our argv commands - safe execution without shell
|
|
1168
|
+
final_argv = [arg.replace('{script}', script_file) for arg in custom_cmd]
|
|
1169
|
+
try:
|
|
1170
|
+
if platform.system() == 'Windows':
|
|
1171
|
+
# Windows needs non-blocking for parallel launches
|
|
1172
|
+
subprocess.Popen(final_argv)
|
|
1173
|
+
return True # Popen is non-blocking, can't check success
|
|
1151
1174
|
else:
|
|
1152
|
-
subprocess.run(
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1175
|
+
result = subprocess.run(final_argv)
|
|
1176
|
+
if result.returncode != 0:
|
|
1177
|
+
return False
|
|
1178
|
+
return True
|
|
1179
|
+
except Exception as e:
|
|
1180
|
+
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
1181
|
+
return False
|
|
1160
1182
|
else:
|
|
1161
|
-
#
|
|
1162
|
-
|
|
1183
|
+
# User-provided string commands - parse safely without shell=True
|
|
1184
|
+
try:
|
|
1185
|
+
final_argv = _parse_terminal_command(custom_cmd, script_file)
|
|
1186
|
+
except ValueError as e:
|
|
1187
|
+
print(str(e), file=sys.stderr)
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
try:
|
|
1191
|
+
if platform.system() == 'Windows':
|
|
1192
|
+
# Windows needs non-blocking for parallel launches
|
|
1193
|
+
subprocess.Popen(final_argv)
|
|
1194
|
+
return True # Popen is non-blocking, can't check success
|
|
1195
|
+
else:
|
|
1196
|
+
result = subprocess.run(final_argv)
|
|
1197
|
+
if result.returncode != 0:
|
|
1198
|
+
return False
|
|
1199
|
+
return True
|
|
1200
|
+
except Exception as e:
|
|
1201
|
+
print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
|
|
1202
|
+
return False
|
|
1163
1203
|
|
|
1164
1204
|
def setup_hooks():
|
|
1165
1205
|
"""Set up Claude hooks in current directory"""
|
|
@@ -1598,12 +1638,7 @@ def show_instances_by_directory():
|
|
|
1598
1638
|
last_tool_name = pos_data.get("last_tool_name", "unknown")
|
|
1599
1639
|
last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
|
|
1600
1640
|
|
|
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}")
|
|
1641
|
+
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
1642
|
print()
|
|
1608
1643
|
else:
|
|
1609
1644
|
print(f" {DIM}Error reading instance data{RESET}")
|
|
@@ -1614,12 +1649,8 @@ def alt_screen_detailed_status_and_input():
|
|
|
1614
1649
|
|
|
1615
1650
|
try:
|
|
1616
1651
|
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}")
|
|
1652
|
+
print(f"{BOLD}HCOM{RESET} STATUS {DIM}- UPDATED: {timestamp}{RESET}")
|
|
1653
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1623
1654
|
print()
|
|
1624
1655
|
|
|
1625
1656
|
show_instances_by_directory()
|
|
@@ -1630,11 +1661,11 @@ def alt_screen_detailed_status_and_input():
|
|
|
1630
1661
|
show_recent_activity_alt_screen()
|
|
1631
1662
|
|
|
1632
1663
|
print()
|
|
1633
|
-
print(f"{
|
|
1634
|
-
print(f"{FG_GREEN}
|
|
1664
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1665
|
+
print(f"{FG_GREEN} Press Enter to send message (empty to cancel):{RESET}")
|
|
1635
1666
|
message = input(f"{FG_CYAN} > {RESET}")
|
|
1636
|
-
|
|
1637
|
-
print(f"{
|
|
1667
|
+
|
|
1668
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
1638
1669
|
|
|
1639
1670
|
finally:
|
|
1640
1671
|
sys.stdout.write("\033[?1049l")
|
|
@@ -1817,15 +1848,10 @@ def show_main_screen_header():
|
|
|
1817
1848
|
all_messages = []
|
|
1818
1849
|
if log_file.exists():
|
|
1819
1850
|
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}")
|
|
1851
|
+
# message_count = len(all_messages)
|
|
1824
1852
|
|
|
1825
|
-
|
|
1826
|
-
print(f"
|
|
1827
|
-
print(f" {DIM}LOGS: {log_file} ({message_count} messages){RESET}")
|
|
1828
|
-
print(f"{BOLD}{'='*50}{RESET}\n")
|
|
1853
|
+
print(f"{BOLD}HCOM{RESET} LOGS")
|
|
1854
|
+
print(f"{DIM}{'─'*40}{RESET}\n")
|
|
1829
1855
|
|
|
1830
1856
|
return all_messages
|
|
1831
1857
|
|
|
@@ -1915,7 +1941,7 @@ Key settings (full list in config.json):
|
|
|
1915
1941
|
env_overrides: "custom environment variables for instances"
|
|
1916
1942
|
|
|
1917
1943
|
Temporary environment overrides for any setting (all caps & append HCOM_):
|
|
1918
|
-
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages
|
|
1944
|
+
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages received by instance
|
|
1919
1945
|
export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
|
|
1920
1946
|
|
|
1921
1947
|
EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
|
|
@@ -1993,8 +2019,6 @@ def cmd_open(*args):
|
|
|
1993
2019
|
launched = 0
|
|
1994
2020
|
initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
|
|
1995
2021
|
|
|
1996
|
-
temp_files_to_cleanup = []
|
|
1997
|
-
|
|
1998
2022
|
for idx, instance_type in enumerate(instances):
|
|
1999
2023
|
instance_env = base_env.copy()
|
|
2000
2024
|
|
|
@@ -2020,6 +2044,8 @@ def cmd_open(*args):
|
|
|
2020
2044
|
# Agent instance
|
|
2021
2045
|
try:
|
|
2022
2046
|
agent_content, agent_config = resolve_agent(instance_type)
|
|
2047
|
+
# Mark this as a subagent instance for SessionStart hook
|
|
2048
|
+
instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
|
|
2023
2049
|
# Prepend agent instance awareness to system prompt
|
|
2024
2050
|
agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
|
|
2025
2051
|
agent_content = agent_prefix + agent_content
|
|
@@ -2033,8 +2059,7 @@ def cmd_open(*args):
|
|
|
2033
2059
|
model=agent_model,
|
|
2034
2060
|
tools=agent_tools
|
|
2035
2061
|
)
|
|
2036
|
-
|
|
2037
|
-
temp_files_to_cleanup.append(temp_file)
|
|
2062
|
+
# Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
|
|
2038
2063
|
except (FileNotFoundError, ValueError) as e:
|
|
2039
2064
|
print(str(e), file=sys.stderr)
|
|
2040
2065
|
continue
|
|
@@ -2046,31 +2071,38 @@ def cmd_open(*args):
|
|
|
2046
2071
|
print(f"Background instance launched, log: {log_file}")
|
|
2047
2072
|
launched += 1
|
|
2048
2073
|
else:
|
|
2049
|
-
launch_terminal(claude_cmd, instance_env, cwd=os.getcwd())
|
|
2050
|
-
|
|
2074
|
+
if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
|
|
2075
|
+
launched += 1
|
|
2051
2076
|
except Exception as e:
|
|
2052
2077
|
print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
|
|
2053
2078
|
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2079
|
+
requested = len(instances)
|
|
2080
|
+
failed = requested - launched
|
|
2081
|
+
|
|
2058
2082
|
if launched == 0:
|
|
2059
|
-
print(format_error("No instances launched"), file=sys.stderr)
|
|
2083
|
+
print(format_error(f"No instances launched (0/{requested})"), file=sys.stderr)
|
|
2060
2084
|
return 1
|
|
2061
|
-
|
|
2062
|
-
#
|
|
2063
|
-
|
|
2085
|
+
|
|
2086
|
+
# Show results
|
|
2087
|
+
if failed > 0:
|
|
2088
|
+
print(f"Launched {launched}/{requested} Claude instance{'s' if requested != 1 else ''} ({failed} failed)")
|
|
2089
|
+
else:
|
|
2090
|
+
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
2064
2091
|
|
|
2065
2092
|
# Auto-launch watch dashboard if configured and conditions are met
|
|
2066
2093
|
terminal_mode = get_config_value('terminal_mode')
|
|
2067
2094
|
auto_watch = get_config_value('auto_watch', True)
|
|
2068
2095
|
|
|
2069
|
-
|
|
2096
|
+
# Only auto-watch if ALL instances launched successfully
|
|
2097
|
+
if terminal_mode == 'new_window' and auto_watch and failed == 0 and is_interactive():
|
|
2070
2098
|
# Show tips first if needed
|
|
2071
2099
|
if prefix:
|
|
2072
2100
|
print(f"\n • Send to {prefix} team: hcom send '@{prefix} message'")
|
|
2073
2101
|
|
|
2102
|
+
# Clear transition message
|
|
2103
|
+
print("\nOpening hcom watch...")
|
|
2104
|
+
time.sleep(2) # Brief pause so user sees the message
|
|
2105
|
+
|
|
2074
2106
|
# Launch interactive watch dashboard in current terminal
|
|
2075
2107
|
return cmd_watch()
|
|
2076
2108
|
else:
|
|
@@ -2081,7 +2113,7 @@ def cmd_open(*args):
|
|
|
2081
2113
|
tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
|
|
2082
2114
|
|
|
2083
2115
|
if tips:
|
|
2084
|
-
print("\n" + "\n".join(f" • {tip}" for tip in tips))
|
|
2116
|
+
print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
|
|
2085
2117
|
|
|
2086
2118
|
# Show cli_hints if configured (non-interactive mode)
|
|
2087
2119
|
if not is_interactive():
|
|
@@ -2102,7 +2134,7 @@ def cmd_watch(*args):
|
|
|
2102
2134
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2103
2135
|
|
|
2104
2136
|
if not log_file.exists() and not instances_dir.exists():
|
|
2105
|
-
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2137
|
+
print(format_error("No conversation log found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2106
2138
|
return 1
|
|
2107
2139
|
|
|
2108
2140
|
# Parse arguments
|
|
@@ -2249,10 +2281,11 @@ def cmd_watch(*args):
|
|
|
2249
2281
|
all_messages = show_main_screen_header()
|
|
2250
2282
|
|
|
2251
2283
|
show_recent_messages(all_messages, limit=5)
|
|
2252
|
-
print(f"\n{DIM}
|
|
2253
|
-
|
|
2284
|
+
print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
|
|
2285
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
2286
|
+
|
|
2254
2287
|
# Print newline to ensure status starts on its own line
|
|
2255
|
-
print()
|
|
2288
|
+
# print()
|
|
2256
2289
|
|
|
2257
2290
|
current_status = get_status_summary()
|
|
2258
2291
|
update_status(f"{current_status}{status_suffix}")
|
|
@@ -2302,7 +2335,8 @@ def cmd_watch(*args):
|
|
|
2302
2335
|
|
|
2303
2336
|
all_messages = show_main_screen_header()
|
|
2304
2337
|
show_recent_messages(all_messages)
|
|
2305
|
-
print(f"\n{DIM}
|
|
2338
|
+
print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
|
|
2339
|
+
print(f"{DIM}{'─' * 40}{RESET}")
|
|
2306
2340
|
|
|
2307
2341
|
if log_file.exists():
|
|
2308
2342
|
last_pos = log_file.stat().st_size
|
|
@@ -2338,6 +2372,14 @@ def cmd_clear():
|
|
|
2338
2372
|
if deleted_count > 0:
|
|
2339
2373
|
print(f"Cleaned up {deleted_count} temp files")
|
|
2340
2374
|
|
|
2375
|
+
# Clean up old script files (older than 24 hours)
|
|
2376
|
+
scripts_dir = hcom_path(SCRIPTS_DIR)
|
|
2377
|
+
if scripts_dir.exists():
|
|
2378
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2379
|
+
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)
|
|
2380
|
+
if script_count > 0:
|
|
2381
|
+
print(f"Cleaned up {script_count} old script files")
|
|
2382
|
+
|
|
2341
2383
|
# Check if hcom files exist
|
|
2342
2384
|
if not log_file.exists() and not instances_dir.exists():
|
|
2343
2385
|
print("No hcom conversation to clear")
|
|
@@ -2383,7 +2425,7 @@ def cmd_clear():
|
|
|
2383
2425
|
|
|
2384
2426
|
log_file.touch()
|
|
2385
2427
|
clear_all_positions()
|
|
2386
|
-
|
|
2428
|
+
|
|
2387
2429
|
if archived:
|
|
2388
2430
|
print(f"Archived to archive/session-{timestamp}/")
|
|
2389
2431
|
print("Started fresh hcom conversation log")
|
|
@@ -3046,6 +3088,11 @@ def handle_sessionstart(hook_data, instance_name, updates):
|
|
|
3046
3088
|
# Always show base help text
|
|
3047
3089
|
help_text = "[Welcome! HCOM chat active. Send messages: echo 'HCOM_SEND:your message']"
|
|
3048
3090
|
|
|
3091
|
+
# Add subagent type if this is a named agent
|
|
3092
|
+
subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
|
|
3093
|
+
if subagent_type:
|
|
3094
|
+
help_text += f" [Subagent: {subagent_type}]"
|
|
3095
|
+
|
|
3049
3096
|
# Add first use text only on startup
|
|
3050
3097
|
if source == 'startup':
|
|
3051
3098
|
first_use_text = get_config_value('first_use_text', '')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hcom
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
|
|
5
5
|
Author: aannoo
|
|
6
6
|
License-Expression: MIT
|
|
@@ -28,11 +28,11 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
# hcom - Claude Hook Comms
|
|
29
29
|
|
|
30
30
|
[](https://pypi.org/project/hcom/)
|
|
31
|
-
[](https://opensource.org/license/MIT) [](https://python.org)
|
|
31
|
+
[](https://opensource.org/license/MIT) [](https://python.org) [](https://deepwiki.com/aannoo/claude-hook-comms)
|
|
32
32
|
|
|
33
|
-
CLI tool for launching multiple Claude Code terminals with interactive [subagents](https://docs.anthropic.com/en/docs/claude-code/sub-agents), headless persistence, and real-time communication via [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks). Works on Mac, Linux, and
|
|
33
|
+
CLI tool for launching multiple Claude Code terminals with interactive [subagents](https://docs.anthropic.com/en/docs/claude-code/sub-agents), headless persistence, and real-time communication via [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks). Works on Mac, Linux, Windows, and Android with zero dependencies.
|
|
34
34
|
|
|
35
|
-

|
|
36
36
|
|
|
37
37
|
## 🥦 Usage
|
|
38
38
|
|
|
@@ -50,28 +50,28 @@ hcom open 2
|
|
|
50
50
|
| Commands | |
|
|
51
51
|
|---------|-------------|
|
|
52
52
|
| `hcom open [n]` | Launch `n` instances or named agents |
|
|
53
|
-
| `hcom watch` |
|
|
54
|
-
| `hcom clear` |
|
|
53
|
+
| `hcom watch` | View live dashboard and messaging |
|
|
54
|
+
| `hcom clear` | Clear and start new conversation |
|
|
55
55
|
| `hcom cleanup` | Safely remove hcom hooks, preserving your project settings |
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
## 🦆 What It Does
|
|
59
59
|
|
|
60
|
-
`hcom open` adds hooks to the `.claude/settings.local.json` file in the current folder and launches terminals with claude code that remain active, waiting to respond to messages in the shared chat.
|
|
60
|
+
`hcom open` adds hooks to the `.claude/settings.local.json` file in the current folder and launches terminals with claude code that remain active, waiting to respond to messages in the shared chat. Normal Claude Code opened with `claude` remains unaffected by hcom.
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
### Subagents in their own terminal
|
|
63
63
|
```bash
|
|
64
64
|
# Launch subagents from your .claude/agents
|
|
65
65
|
hcom open planner code-writer reviewer
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
### Persistent headless instances
|
|
69
69
|
```bash
|
|
70
70
|
# Launch one headless instance (default 30min timeout)
|
|
71
71
|
hcom open -p
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
### Groups and direct messages
|
|
75
75
|
```bash
|
|
76
76
|
hcom open --prefix cool # Creates cool-hovoa7
|
|
77
77
|
hcom open --prefix cool # Creates cool-homab8
|
|
@@ -79,6 +79,13 @@ hcom send '@cool hi, you are cool'
|
|
|
79
79
|
hcom send '@homab8 hi, you are cooler'
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
### Persistent thinking mode
|
|
83
|
+
```bash
|
|
84
|
+
# Thinking mode maintains for entire session
|
|
85
|
+
HCOM_INITIAL_PROMPT="ultrathink and do x" hcom open
|
|
86
|
+
# Every new message reply uses ultrathink
|
|
87
|
+
```
|
|
88
|
+
|
|
82
89
|
---
|
|
83
90
|
|
|
84
91
|
|
|
@@ -93,6 +100,7 @@ hcom send '@homab8 hi, you are cooler'
|
|
|
93
100
|
- **@Mention Targeting** - Send messages to specific instances or teams
|
|
94
101
|
- **Session Persistence** - Resume previous conversations automatically
|
|
95
102
|
- **Zero Dependencies** - Pure Python stdlib, works everywhere
|
|
103
|
+
- **Cross-platform** - Native support for Windows, WSL, macOS, Linux, Android
|
|
96
104
|
|
|
97
105
|
</details>
|
|
98
106
|
|
|
@@ -140,7 +148,7 @@ hcom open --prefix notcool # creates notcool-hovoc9
|
|
|
140
148
|
|
|
141
149
|
# Launch 3 headless instances that die after 60 seconds of inactivity
|
|
142
150
|
HCOM_WAIT_TIMEOUT="60" hcom open 3 -p
|
|
143
|
-
# Manually kill all
|
|
151
|
+
# Manually kill all instances
|
|
144
152
|
hcom kill --all
|
|
145
153
|
|
|
146
154
|
# Launch multiple of the same subagent
|
|
@@ -222,6 +230,9 @@ $env:HCOM_INITIAL_PROMPT="go home buddy!"; hcom open
|
|
|
222
230
|
```
|
|
223
231
|
|
|
224
232
|
### Status Indicators
|
|
233
|
+
|
|
234
|
+
When running `hcom watch`, each instance shows its current state:
|
|
235
|
+
|
|
225
236
|
- ◉ **thinking** (cyan) - Processing input
|
|
226
237
|
- ▷ **responding** (green) - Generating text response
|
|
227
238
|
- ▶ **executing** (green) - Running tools
|
|
@@ -240,13 +251,13 @@ $env:HCOM_INITIAL_PROMPT="go home buddy!"; hcom open
|
|
|
240
251
|
hcom adds hooks to your project directory's `.claude/settings.local.json`:
|
|
241
252
|
|
|
242
253
|
1. **Sending**: Claude agents use `echo "HCOM_SEND:message"` internally (you use `hcom send` from terminal or dashboard)
|
|
243
|
-
2. **Receiving**: Other Claudes get notified via Stop
|
|
254
|
+
2. **Receiving**: Other Claudes get notified via hooks (PostToolUse or Stop)
|
|
244
255
|
3. **Waiting**: Stop hook keeps Claude in a waiting state for new messages
|
|
245
256
|
|
|
246
257
|
- **Identity**: Each instance gets a unique name based on session ID (e.g., "hovoa7")
|
|
247
258
|
- **Persistence**: Names persist across `--resume` maintaining conversation context
|
|
248
259
|
- **Status Detection**: Notification hook tracks permission requests and activity
|
|
249
|
-
- **Agents**: When you run `hcom open researcher`, it loads an interactive Claude session with a system prompt from `.claude/agents/researcher.md` (local) or `~/.claude/agents/researcher.md` (global). Specified `model:` and `tools:` are carried over
|
|
260
|
+
- **Agents**: When you run `hcom open researcher`, it loads an interactive Claude session with a system prompt from `.claude/agents/researcher.md` (local) or `~/.claude/agents/researcher.md` (global). Specified `model:` and `tools:` are carried over
|
|
250
261
|
|
|
251
262
|
### Architecture
|
|
252
263
|
- **Single conversation** - All instances share one global conversation
|
|
@@ -254,7 +265,7 @@ hcom adds hooks to your project directory's `.claude/settings.local.json`:
|
|
|
254
265
|
- **@-mention filtering** - Target messages to specific instances or teams
|
|
255
266
|
|
|
256
267
|
### File Structure
|
|
257
|
-
```
|
|
268
|
+
```plaintext
|
|
258
269
|
~/.hcom/
|
|
259
270
|
├── hcom.log # Conversation log
|
|
260
271
|
├── instances/ # Instance tracking
|
|
@@ -278,21 +289,22 @@ your-project/
|
|
|
278
289
|
Configure terminal behavior in `~/.hcom/config.json`:
|
|
279
290
|
- `"terminal_mode": "new_window"` - Opens new terminal window(s) (default)
|
|
280
291
|
- `"terminal_mode": "same_terminal"` - Opens in current terminal
|
|
281
|
-
|
|
292
|
+
|
|
293
|
+
#### Running in current terminal temporarily
|
|
294
|
+
```bash
|
|
295
|
+
# For single instances
|
|
296
|
+
HCOM_TERMINAL_MODE=same_terminal hcom open
|
|
297
|
+
```
|
|
282
298
|
|
|
283
299
|
### Default Terminals
|
|
284
300
|
|
|
285
301
|
- **macOS**: Terminal.app
|
|
286
302
|
- **Linux**: gnome-terminal, konsole, or xterm
|
|
287
303
|
- **Windows & WSL**: Windows Terminal / Git Bash
|
|
304
|
+
- **Android**: Termux
|
|
288
305
|
|
|
289
|
-
### Running in Current Terminal temporarily
|
|
290
|
-
```bash
|
|
291
|
-
# For single instances
|
|
292
|
-
HCOM_TERMINAL_MODE=same_terminal hcom open
|
|
293
|
-
```
|
|
294
306
|
|
|
295
|
-
### Custom
|
|
307
|
+
### Custom Terminals
|
|
296
308
|
|
|
297
309
|
Configure `terminal_command` in `~/.hcom/config.json` (permanent) or environment variables (temporary).
|
|
298
310
|
|
|
@@ -306,78 +318,91 @@ Your custom command just needs to:
|
|
|
306
318
|
|
|
307
319
|
Example template: `your_terminal_command --execute "bash {script}"`
|
|
308
320
|
|
|
309
|
-
###
|
|
310
|
-
```json
|
|
311
|
-
"terminal_command": "osascript -e 'tell app \"iTerm\" to tell (create window with default profile) to tell current session to write text \"{script}\"'"
|
|
312
|
-
```
|
|
321
|
+
### Custom Terminal Examples
|
|
313
322
|
|
|
314
|
-
|
|
315
|
-
Windows:
|
|
316
|
-
```json
|
|
317
|
-
"terminal_command": "wezterm start -- bash {script}"
|
|
318
|
-
```
|
|
319
|
-
Or open tabs from within WezTerm:
|
|
320
|
-
```json
|
|
321
|
-
"terminal_command": "wezterm cli spawn -- bash {script}"
|
|
322
|
-
```
|
|
323
|
-
macOS/Linux:
|
|
323
|
+
#### iTerm2
|
|
324
324
|
```json
|
|
325
|
-
|
|
325
|
+
"terminal_command": "open -a iTerm {script}"
|
|
326
326
|
```
|
|
327
327
|
|
|
328
|
-
|
|
329
|
-
Windows. From within Wave Terminal:
|
|
328
|
+
#### [ttab](https://github.com/mklement0/ttab) (new tab instead of new window in Terminal.app)
|
|
330
329
|
```json
|
|
331
|
-
|
|
330
|
+
"terminal_command": "ttab {script}"
|
|
332
331
|
```
|
|
333
332
|
|
|
334
|
-
|
|
335
|
-
macOS:
|
|
336
|
-
```json
|
|
337
|
-
"terminal_command": "open -n -a Alacritty.app --args -e bash {script}"
|
|
338
|
-
```
|
|
339
|
-
Linux:
|
|
333
|
+
#### [wttab](https://github.com/lalilaloe/wttab) (new tab in Windows Terminal)
|
|
340
334
|
```json
|
|
341
|
-
|
|
335
|
+
"terminal_command": "wttab {script}"
|
|
342
336
|
```
|
|
343
337
|
|
|
344
|
-
|
|
345
|
-
macOS:
|
|
338
|
+
#### More
|
|
346
339
|
```json
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
Linux:
|
|
350
|
-
```json
|
|
351
|
-
"terminal_command": "kitty bash {script}"
|
|
352
|
-
```
|
|
340
|
+
# WezTerm Linux/Windows
|
|
341
|
+
"terminal_command": "wezterm start -- bash {script}"
|
|
353
342
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
343
|
+
# Tabs from within WezTerm
|
|
344
|
+
"terminal_command": "wezterm cli spawn -- bash {script}"
|
|
345
|
+
|
|
346
|
+
# WezTerm macOS:
|
|
347
|
+
"terminal_command": "open -n -a WezTerm.app --args start -- bash {script}"
|
|
348
|
+
|
|
349
|
+
# Tabs from within WezTerm macOS
|
|
350
|
+
"terminal_command": "/Applications/WezTerm.app/Contents/MacOS/wezterm cli spawn -- bash {script}"
|
|
351
|
+
|
|
352
|
+
# Wave Terminal Mac/Linux/Windows. From within Wave Terminal:
|
|
353
|
+
"terminal_command": "wsh run -- bash {script}"
|
|
354
|
+
|
|
355
|
+
# Alacritty macOS:
|
|
356
|
+
"terminal_command": "open -n -a Alacritty.app --args -e bash {script}"
|
|
357
|
+
|
|
358
|
+
# Alacritty Linux:
|
|
359
|
+
"terminal_command": "alacritty -e bash {script}"
|
|
360
|
+
|
|
361
|
+
# Kitty macOS:
|
|
362
|
+
"terminal_command": "open -n -a kitty.app --args {script}"
|
|
363
|
+
|
|
364
|
+
# Kitty Linux
|
|
365
|
+
"terminal_command": "kitty {script}"
|
|
357
366
|
```
|
|
358
|
-
Note: Requires `allow-external-apps=true` in `~/.termux/termux.properties`
|
|
359
367
|
|
|
360
|
-
|
|
368
|
+
#### tmux
|
|
361
369
|
```json
|
|
362
|
-
|
|
370
|
+
"terminal_command": "tmux new-window -n hcom {script}"
|
|
363
371
|
```
|
|
364
|
-
Then from a terminal:
|
|
365
|
-
```bash
|
|
366
|
-
# Run hcom open directly in new session
|
|
367
|
-
tmux new-session 'hcom open 3'
|
|
368
|
-
```
|
|
369
|
-
Or once off:
|
|
370
372
|
```bash
|
|
373
|
+
# tmux commands work inside tmux, start a session with:
|
|
374
|
+
tmux new-session 'hcom open 3' # each instance opens in new tmux window
|
|
375
|
+
|
|
376
|
+
# Or one time split-panes:
|
|
371
377
|
# Start tmux with split panes and 3 claude instances in hcom chat
|
|
372
378
|
HCOM_TERMINAL_COMMAND="tmux split-window -h {script}" hcom open 3
|
|
373
379
|
```
|
|
374
380
|
|
|
381
|
+
### Android (Termux)
|
|
382
|
+
|
|
383
|
+
1. Install [Termux](https://f-droid.org/packages/com.termux/) from F-Droid (not Google Play)
|
|
384
|
+
2. Setup:
|
|
385
|
+
```bash
|
|
386
|
+
pkg install python nodejs
|
|
387
|
+
npm install -g @anthropic-ai/claude-cli
|
|
388
|
+
pip install hcom
|
|
389
|
+
```
|
|
390
|
+
3. Enable:
|
|
391
|
+
```bash
|
|
392
|
+
echo "allow-external-apps=true" >> ~/.termux/termux.properties
|
|
393
|
+
termux-reload-settings
|
|
394
|
+
```
|
|
395
|
+
4. Enable: "Display over other apps" permission for visible terminals
|
|
396
|
+
|
|
397
|
+
5. Run: `hcom open`
|
|
398
|
+
|
|
399
|
+
---
|
|
375
400
|
|
|
376
401
|
</details>
|
|
377
402
|
|
|
378
403
|
|
|
379
404
|
<details>
|
|
380
|
-
<summary><strong
|
|
405
|
+
<summary><strong>⚗️ Remove</strong></summary>
|
|
381
406
|
|
|
382
407
|
|
|
383
408
|
### Archive Conversation / Start New
|
|
@@ -416,6 +441,7 @@ hcom cleanup --all
|
|
|
416
441
|
- [Claude Code](https://claude.ai/code)
|
|
417
442
|
|
|
418
443
|
|
|
444
|
+
|
|
419
445
|
## 🌮 License
|
|
420
446
|
|
|
421
447
|
- MIT License
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
hcom/__init__.py,sha256=jnDZJt18O1suCga9s5eFchG-doPpA0mGK4xBcJ0OJNk,96
|
|
2
|
+
hcom/__main__.py,sha256=_i5IjRCVvoLrq1JvgFN_SgBv7b8s-cJx1bUz8x97Ntw,123527
|
|
3
|
+
hcom-0.2.2.dist-info/METADATA,sha256=BoB4vN-QI4VE8pUiA8bawrw7l1lRbIN2bPS6TzbEr-s,15834
|
|
4
|
+
hcom-0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
hcom-0.2.2.dist-info/entry_points.txt,sha256=cz9K9PsgYmORUxNKxVRrpxLS3cxRJcDZkE-PpfvOhI8,44
|
|
6
|
+
hcom-0.2.2.dist-info/top_level.txt,sha256=8AS1nVUWA26vxjDQ5viRxgJnwSvUWk1W6GP4g6ldZ-0,5
|
|
7
|
+
hcom-0.2.2.dist-info/RECORD,,
|
hcom-0.2.1.dist-info/RECORD
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
hcom/__init__.py,sha256=98Swn5DWHZchyvAuL0ygbd3uPwt1lCLXL-RQDtab30M,96
|
|
2
|
-
hcom/__main__.py,sha256=hzOsy6CTDYMc_vsEU4oU1HQADAfLojB0w59aLb3wiNs,121986
|
|
3
|
-
hcom-0.2.1.dist-info/METADATA,sha256=JJOnLHrbFXzYuSfvg4RYXnlUPAIuRcqR7c9zswP0nWQ,13591
|
|
4
|
-
hcom-0.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
-
hcom-0.2.1.dist-info/entry_points.txt,sha256=cz9K9PsgYmORUxNKxVRrpxLS3cxRJcDZkE-PpfvOhI8,44
|
|
6
|
-
hcom-0.2.1.dist-info/top_level.txt,sha256=8AS1nVUWA26vxjDQ5viRxgJnwSvUWk1W6GP4g6ldZ-0,5
|
|
7
|
-
hcom-0.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|