hcom 0.6.0__tar.gz → 0.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hcom might be problematic. Click here for more details.
- {hcom-0.6.0/src/hcom.egg-info → hcom-0.6.1}/PKG-INFO +1 -1
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom/cli.py +243 -103
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom/shared.py +173 -3
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom/ui.py +27 -12
- {hcom-0.6.0 → hcom-0.6.1/src/hcom.egg-info}/PKG-INFO +1 -1
- {hcom-0.6.0 → hcom-0.6.1}/MANIFEST.in +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/README.md +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/pyproject.toml +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/setup.cfg +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom/__init__.py +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom/__main__.py +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom.egg-info/SOURCES.txt +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom.egg-info/dependency_links.txt +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom.egg-info/entry_points.txt +0 -0
- {hcom-0.6.0 → hcom-0.6.1}/src/hcom.egg-info/top_level.txt +0 -0
|
@@ -39,7 +39,7 @@ from .shared import (
|
|
|
39
39
|
# Utilities
|
|
40
40
|
format_age, get_status_counts,
|
|
41
41
|
# Claude args parsing
|
|
42
|
-
resolve_claude_args, add_background_defaults, validate_conflicts,
|
|
42
|
+
resolve_claude_args, merge_claude_args, add_background_defaults, validate_conflicts,
|
|
43
43
|
extract_system_prompt_args, merge_system_prompts,
|
|
44
44
|
)
|
|
45
45
|
|
|
@@ -306,11 +306,11 @@ HCOM_HOOK_PATTERNS = [
|
|
|
306
306
|
# - hcom help | hcom --help | hcom -h
|
|
307
307
|
# - hcom watch --status | hcom watch --launch | hcom watch --logs | hcom watch --wait
|
|
308
308
|
# Supports: hcom, uvx hcom, python -m hcom, python hcom.py, python hcom.pyz, /path/to/hcom.py[z]
|
|
309
|
-
# Negative lookahead ensures stop/start not followed by alias targets (except --_hcom_sender
|
|
309
|
+
# Negative lookahead ensures stop/start/done not followed by alias targets (except --_hcom_sender)
|
|
310
310
|
# Allows shell operators (2>&1, >/dev/null, |, &&) but blocks identifier-like targets (myalias, 123abc)
|
|
311
311
|
HCOM_COMMAND_PATTERN = re.compile(
|
|
312
312
|
r'((?:uvx\s+)?hcom|python3?\s+-m\s+hcom|(?:python3?\s+)?\S*hcom\.pyz?)\s+'
|
|
313
|
-
r'(?:send\b|stop(?!\s+(?:[a-zA-Z_]|[0-9]+[a-zA-Z_])[-\w]*(?:\s|$))|start(?:\s+--_hcom_sender\s+\S+)?(?!\s+(?:[a-zA-Z_]|[0-9]+[a-zA-Z_])[-\w]*(?:\s|$))|(?:help|--help|-h)\b|watch\s+(?:--status|--launch|--logs|--wait)\b)'
|
|
313
|
+
r'(?:send\b|stop(?!\s+(?:[a-zA-Z_]|[0-9]+[a-zA-Z_])[-\w]*(?:\s|$))|start(?:\s+--_hcom_sender\s+\S+)?(?!\s+(?:[a-zA-Z_]|[0-9]+[a-zA-Z_])[-\w]*(?:\s|$))|done(?:\s+--_hcom_sender\s+\S+)?(?!\s+(?:[a-zA-Z_]|[0-9]+[a-zA-Z_])[-\w]*(?:\s|$))|(?:help|--help|-h)\b|watch\s+(?:--status|--launch|--logs|--wait)\b)'
|
|
314
314
|
)
|
|
315
315
|
|
|
316
316
|
# ==================== File System Utilities ====================
|
|
@@ -602,6 +602,13 @@ class HcomConfig:
|
|
|
602
602
|
# Parse config file once
|
|
603
603
|
file_config = _parse_env_file(config_path) if config_path.exists() else {}
|
|
604
604
|
|
|
605
|
+
# Auto-migrate from HCOM_PROMPT (0.5.0) to HCOM_CLAUDE_ARGS
|
|
606
|
+
if 'HCOM_PROMPT' in file_config:
|
|
607
|
+
print(f"Migrating config to {__version__}...")
|
|
608
|
+
cmd_reset(['config']) # Backs up and deletes old config
|
|
609
|
+
_write_default_config(config_path) # Write new defaults
|
|
610
|
+
file_config = _parse_env_file(config_path) # Re-parse new config
|
|
611
|
+
|
|
605
612
|
def get_var(key: str) -> str | None:
|
|
606
613
|
"""Get variable with precedence: env → file"""
|
|
607
614
|
if key in os.environ:
|
|
@@ -938,7 +945,7 @@ def get_update_notice() -> str | None:
|
|
|
938
945
|
return None
|
|
939
946
|
|
|
940
947
|
cmd = "uv tool upgrade hcom" if _detect_hcom_command_type() == 'uvx' else "pip install -U hcom"
|
|
941
|
-
return f"→ hcom v{latest}
|
|
948
|
+
return f"→ Update available: hcom v{latest} ({cmd})"
|
|
942
949
|
except Exception:
|
|
943
950
|
return None
|
|
944
951
|
|
|
@@ -1000,6 +1007,25 @@ def validate_message(message: str) -> str | None:
|
|
|
1000
1007
|
|
|
1001
1008
|
return None
|
|
1002
1009
|
|
|
1010
|
+
def unescape_bash(text: str) -> str:
|
|
1011
|
+
"""Remove bash escape sequences from message content.
|
|
1012
|
+
|
|
1013
|
+
Bash escapes special characters when constructing commands. Since hcom
|
|
1014
|
+
receives messages as command arguments, we unescape common sequences
|
|
1015
|
+
that don't affect the actual message intent.
|
|
1016
|
+
"""
|
|
1017
|
+
# Common bash escapes that appear in double-quoted strings
|
|
1018
|
+
replacements = [
|
|
1019
|
+
('\\!', '!'), # History expansion
|
|
1020
|
+
('\\$', '$'), # Variable expansion
|
|
1021
|
+
('\\`', '`'), # Command substitution
|
|
1022
|
+
('\\"', '"'), # Double quote
|
|
1023
|
+
("\\'", "'"), # Single quote (less common in double quotes but possible)
|
|
1024
|
+
]
|
|
1025
|
+
for escaped, unescaped in replacements:
|
|
1026
|
+
text = text.replace(escaped, unescaped)
|
|
1027
|
+
return text
|
|
1028
|
+
|
|
1003
1029
|
def send_message(from_instance: str, message: str) -> bool:
|
|
1004
1030
|
"""Send a message to the log"""
|
|
1005
1031
|
try:
|
|
@@ -1091,19 +1117,20 @@ GROUP TAG: You are in the '{tag}' group.
|
|
|
1091
1117
|
- If someone outside the group sends you @{tag} messages, they won't see your @{tag} replies. To reply to non-group members, either @mention them directly or broadcast.
|
|
1092
1118
|
"""
|
|
1093
1119
|
|
|
1094
|
-
|
|
1120
|
+
instance_data = load_instance_position(instance_name)
|
|
1095
1121
|
return f"""{command_notice}{tag_notice}
|
|
1096
1122
|
[HCOM SESSION CONFIG]
|
|
1097
|
-
- HCOM is a communication tool for you, other claude code instances, and the human user.
|
|
1123
|
+
- HCOM is a communication tool for you, other claude code instances, and the human user. Aliases are generated randomly.
|
|
1098
1124
|
- Your HCOM alias for this session: {instance_name}
|
|
1125
|
+
- Your hcom connection: {"enabled" if instance_data.get('enabled', False) else "disabled"}
|
|
1099
1126
|
|
|
1100
1127
|
Your HCOM Tools:
|
|
1101
1128
|
- hcom send "msg" (broadcast) / "@alias msg" (direct) / "@tag msg" (tag) - for you
|
|
1102
1129
|
- hcom watch --status → See participants JSON (for you only)
|
|
1103
1130
|
- hcom watch --launch → Open interactive TUI messaging+launch+monitor dashboard in new terminal (for the human user)
|
|
1104
1131
|
- hcom start/stop → Connect/disconnect from chat (you run these, user can't run it themselves unless they specify an alias)
|
|
1105
|
-
- hcom <count> → Launch instances in new terminal (you
|
|
1106
|
-
- Claude code subagents launched with the Task tool can also connect to HCOM, just tell them to
|
|
1132
|
+
- hcom <count> → Launch instances in new terminal (you must always run 'hcom help' first to get correct context/syntax/config defaults)
|
|
1133
|
+
- Claude code subagents launched with the Task tool can also connect to HCOM, just tell them to send messages with 'hcom' (dont give specific hcom commands, just say 'hcom', subagents use different syntax).
|
|
1107
1134
|
|
|
1108
1135
|
UI:
|
|
1109
1136
|
- The human user has the 'hcom' (no args) command.
|
|
@@ -1130,6 +1157,16 @@ Report to the human user using first-person, for example: "I'm connected to HCOM
|
|
|
1130
1157
|
|
|
1131
1158
|
def build_launch_context(instance_name: str) -> str:
|
|
1132
1159
|
"""Build context for launch command"""
|
|
1160
|
+
# Load current config values
|
|
1161
|
+
config_vals = build_claude_env()
|
|
1162
|
+
config_display = ""
|
|
1163
|
+
if config_vals:
|
|
1164
|
+
config_lines = [f" {k}={v}" for k, v in sorted(config_vals.items())]
|
|
1165
|
+
config_display = "\n" + "\n".join(config_lines)
|
|
1166
|
+
else:
|
|
1167
|
+
config_display = "\n (none set)"
|
|
1168
|
+
|
|
1169
|
+
instance_data = load_instance_position(instance_name)
|
|
1133
1170
|
return f"""[HCOM LAUNCH INFORMATION]
|
|
1134
1171
|
BASIC USAGE:
|
|
1135
1172
|
[ENV_VARS] hcom <COUNT> [claude <ARGS>...]
|
|
@@ -1145,22 +1182,21 @@ ENV VARS INFO:
|
|
|
1145
1182
|
KEY CLAUDE ARGS:
|
|
1146
1183
|
Run 'claude --help' for all claude code CLI args. hcom 1 claude [options] [command] [prompt]
|
|
1147
1184
|
-p headless instance
|
|
1148
|
-
--allowedTools=Bash (headless can only hcom chat otherwise
|
|
1185
|
+
--allowedTools=Bash,Write,<morecommaseperatedtools> (headless can only hcom chat otherwise)
|
|
1149
1186
|
--model sonnet/haiku/opus
|
|
1150
1187
|
--resume <sessionid> (get sessionid from hcom watch --status)
|
|
1151
1188
|
--system-prompt (for interactive instances) --append-system-prompt (for headless instances)
|
|
1152
1189
|
Example: HCOM_HINTS='essential responses only' hcom 2 claude --model sonnet -p "do task x"
|
|
1153
1190
|
|
|
1154
|
-
CONTROL:
|
|
1191
|
+
CONTROL:
|
|
1155
1192
|
hcom watch --status JSON status of all instances
|
|
1156
1193
|
hcom watch --logs All messages (pipe to tail)
|
|
1157
1194
|
hcom watch --wait Block until next message (only use when hcom stopped (started is automatic already))
|
|
1158
1195
|
|
|
1159
1196
|
STATUS INDICATORS:
|
|
1160
|
-
"active", "delivered" | "idle" - waiting for new messages
|
|
1197
|
+
"active", "delivered" - working | "idle" - waiting for new messages
|
|
1161
1198
|
"blocked" - permission request (needs user approval)
|
|
1162
|
-
"
|
|
1163
|
-
"unknown" / "stale" - crashed or hung
|
|
1199
|
+
"exited", "stale", "unknown" - timed out, disconnected etc
|
|
1164
1200
|
|
|
1165
1201
|
LAUNCH PATTERNS:
|
|
1166
1202
|
- HCOM_AGENT=reviewer,tester hcom 2 claude "do task x" # 2x reviewers + 2x testers (4 in total) with initial prompt
|
|
@@ -1169,6 +1205,10 @@ LAUNCH PATTERNS:
|
|
|
1169
1205
|
2. HCOM_TAG=clone hcom 3 claude --resume sessionid
|
|
1170
1206
|
- System prompt (or agent file) + initial prompt + hcom_hints is a powerful combination.
|
|
1171
1207
|
|
|
1208
|
+
- Your HCOM alias: {instance_name}
|
|
1209
|
+
- Your hcom connection: {"enabled" if instance_data.get('enabled', False) else "disabled (now is a good time to run 'hcom start')"}
|
|
1210
|
+
Currently set values in ~/.hcom/config.env (CLI args and env vars override these):{config_display}
|
|
1211
|
+
|
|
1172
1212
|
"""
|
|
1173
1213
|
|
|
1174
1214
|
def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
|
|
@@ -1854,13 +1894,29 @@ def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, b
|
|
|
1854
1894
|
"""
|
|
1855
1895
|
# config.env defaults + internal vars, then shell env overrides
|
|
1856
1896
|
env_vars = env.copy()
|
|
1897
|
+
|
|
1898
|
+
# Ensure SHELL is in env dict BEFORE os.environ update
|
|
1899
|
+
# (Critical for Termux Activity Manager which launches scripts in clean environment)
|
|
1900
|
+
if 'SHELL' not in env_vars:
|
|
1901
|
+
shell_path = os.environ.get('SHELL')
|
|
1902
|
+
if not shell_path:
|
|
1903
|
+
shell_path = shutil.which('bash') or shutil.which('sh')
|
|
1904
|
+
if not shell_path:
|
|
1905
|
+
# Platform-specific fallback
|
|
1906
|
+
if is_termux():
|
|
1907
|
+
shell_path = '/data/data/com.termux/files/usr/bin/bash'
|
|
1908
|
+
else:
|
|
1909
|
+
shell_path = '/bin/bash'
|
|
1910
|
+
if shell_path:
|
|
1911
|
+
env_vars['SHELL'] = shell_path
|
|
1912
|
+
|
|
1857
1913
|
env_vars.update(os.environ)
|
|
1858
1914
|
command_str = command
|
|
1859
1915
|
|
|
1860
1916
|
# 1) Always create a script
|
|
1861
1917
|
script_file = str(hcom_path(SCRIPTS_DIR,
|
|
1862
1918
|
f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'))
|
|
1863
|
-
create_bash_script(script_file,
|
|
1919
|
+
create_bash_script(script_file, env_vars, cwd, command_str, background)
|
|
1864
1920
|
|
|
1865
1921
|
# 2) Background mode
|
|
1866
1922
|
if background:
|
|
@@ -2182,6 +2238,39 @@ def parse_log_messages(log_file: Path, start_pos: int = 0) -> LogParseResult:
|
|
|
2182
2238
|
default=LogParseResult([], start_pos)
|
|
2183
2239
|
)
|
|
2184
2240
|
|
|
2241
|
+
def get_position_for_timestamp(log_file: Path, target_timestamp: float) -> int:
|
|
2242
|
+
"""Find byte position in log where messages >= target_timestamp start."""
|
|
2243
|
+
if not log_file.exists():
|
|
2244
|
+
return 0
|
|
2245
|
+
|
|
2246
|
+
try:
|
|
2247
|
+
result = parse_log_messages(log_file, start_pos=0)
|
|
2248
|
+
if not result.messages:
|
|
2249
|
+
return 0
|
|
2250
|
+
|
|
2251
|
+
# Find first message >= target
|
|
2252
|
+
for msg in result.messages:
|
|
2253
|
+
try:
|
|
2254
|
+
msg_time = datetime.fromisoformat(msg['timestamp']).timestamp()
|
|
2255
|
+
if msg_time >= target_timestamp:
|
|
2256
|
+
# Found it - search for byte position of this timestamp
|
|
2257
|
+
with open(log_file, 'rb') as f:
|
|
2258
|
+
content = f.read()
|
|
2259
|
+
# Search for exact timestamp string at start of line
|
|
2260
|
+
search_str = f"{msg['timestamp']}|".encode('utf-8')
|
|
2261
|
+
pos = content.find(search_str)
|
|
2262
|
+
if pos >= 0:
|
|
2263
|
+
return pos
|
|
2264
|
+
# Fallback if exact match not found
|
|
2265
|
+
return 0
|
|
2266
|
+
except (ValueError, AttributeError):
|
|
2267
|
+
continue
|
|
2268
|
+
# All messages older than target - skip all history
|
|
2269
|
+
return result.end_position
|
|
2270
|
+
|
|
2271
|
+
except Exception:
|
|
2272
|
+
return 0
|
|
2273
|
+
|
|
2185
2274
|
def get_subagent_messages(parent_name: str, since_pos: int = 0, limit: int = 0) -> tuple[list[dict[str, str]], int, dict[str, int]]:
|
|
2186
2275
|
"""Get messages from/to subagents of parent instance
|
|
2187
2276
|
Args:
|
|
@@ -2372,12 +2461,20 @@ def initialize_instance_in_position_file(instance_name: str, session_id: str | N
|
|
|
2372
2461
|
# Determine default enabled state: True for hcom-launched, False for vanilla
|
|
2373
2462
|
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
2374
2463
|
|
|
2375
|
-
# Determine starting position: skip history or read from beginning
|
|
2464
|
+
# Determine starting position: skip history or read from beginning
|
|
2376
2465
|
initial_pos = 0
|
|
2377
2466
|
if SKIP_HISTORY:
|
|
2378
2467
|
log_file = hcom_path(LOG_FILE)
|
|
2379
2468
|
if log_file.exists():
|
|
2380
|
-
|
|
2469
|
+
# Get launch time from env var (set at hcom launch)
|
|
2470
|
+
launch_time_str = os.environ.get('HCOM_LAUNCH_TIME')
|
|
2471
|
+
if launch_time_str:
|
|
2472
|
+
# Show messages from launch time onwards
|
|
2473
|
+
launch_time = float(launch_time_str)
|
|
2474
|
+
initial_pos = get_position_for_timestamp(log_file, launch_time)
|
|
2475
|
+
else:
|
|
2476
|
+
# No launch time (vanilla instance) - skip all history
|
|
2477
|
+
initial_pos = log_file.stat().st_size
|
|
2381
2478
|
|
|
2382
2479
|
# Determine enabled state: explicit param > hcom-launched > False
|
|
2383
2480
|
if enabled is not None:
|
|
@@ -2502,11 +2599,15 @@ def cmd_launch(argv: list[str]) -> int:
|
|
|
2502
2599
|
agent_env = get_config().agent
|
|
2503
2600
|
agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['']
|
|
2504
2601
|
|
|
2505
|
-
# Phase 1: Parse Claude args
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2602
|
+
# Phase 1: Parse and merge Claude args (env + CLI with CLI precedence)
|
|
2603
|
+
env_spec = resolve_claude_args(None, get_config().claude_args)
|
|
2604
|
+
cli_spec = resolve_claude_args(forwarded if forwarded else None, None)
|
|
2605
|
+
|
|
2606
|
+
# Merge: CLI overrides env on per-flag basis, inherits env if CLI has no args
|
|
2607
|
+
if cli_spec.clean_tokens or cli_spec.positional_tokens or cli_spec.system_entries:
|
|
2608
|
+
spec = merge_claude_args(env_spec, cli_spec)
|
|
2609
|
+
else:
|
|
2610
|
+
spec = env_spec
|
|
2510
2611
|
|
|
2511
2612
|
# Validate parsed args
|
|
2512
2613
|
if spec.has_errors():
|
|
@@ -2559,8 +2660,9 @@ def cmd_launch(argv: list[str]) -> int:
|
|
|
2559
2660
|
instance_type = agent
|
|
2560
2661
|
instance_env = base_env.copy()
|
|
2561
2662
|
|
|
2562
|
-
# Mark all hcom-launched instances
|
|
2663
|
+
# Mark all hcom-launched instances with timestamp
|
|
2563
2664
|
instance_env['HCOM_LAUNCHED'] = '1'
|
|
2665
|
+
instance_env['HCOM_LAUNCH_TIME'] = str(time.time())
|
|
2564
2666
|
|
|
2565
2667
|
# Mark background instances via environment with log filename
|
|
2566
2668
|
if background:
|
|
@@ -3032,7 +3134,7 @@ def cmd_stop(argv: list[str]) -> int:
|
|
|
3032
3134
|
return 1
|
|
3033
3135
|
|
|
3034
3136
|
if not position:
|
|
3035
|
-
print(f"
|
|
3137
|
+
print(format_error(f"Instance '{instance_name}' not found"), file=sys.stderr)
|
|
3036
3138
|
return 1
|
|
3037
3139
|
|
|
3038
3140
|
# Skip already stopped instances
|
|
@@ -3049,7 +3151,6 @@ def cmd_stop(argv: list[str]) -> int:
|
|
|
3049
3151
|
|
|
3050
3152
|
for name, data in positions.items():
|
|
3051
3153
|
if data.get('parent_session_id') == parent_session_id and data.get('enabled', False):
|
|
3052
|
-
# Set external stop flag (subagents can't self-stop, so any stop is external)
|
|
3053
3154
|
update_instance_position(name, {'external_stop_pending': True})
|
|
3054
3155
|
disable_instance(name)
|
|
3055
3156
|
disabled_count += 1
|
|
@@ -3114,11 +3215,12 @@ def cmd_start(argv: list[str]) -> int:
|
|
|
3114
3215
|
print(f"Error: Instance '{sender_override}' not found or has exited", file=sys.stderr)
|
|
3115
3216
|
return 1
|
|
3116
3217
|
|
|
3218
|
+
already = 'already ' if instance_data.get('enabled', False) else ''
|
|
3117
3219
|
enable_instance(sender_override)
|
|
3118
3220
|
set_status(sender_override, 'active', 'start')
|
|
3119
|
-
print(f"HCOM started for {sender_override}")
|
|
3221
|
+
print(f"HCOM {already} started for {sender_override}")
|
|
3120
3222
|
print(f"Send: hcom send 'message' --_hcom_sender {sender_override}")
|
|
3121
|
-
print(f"When finished working always run: hcom
|
|
3223
|
+
print(f"When finished working always run: hcom done --_hcom_sender {sender_override}")
|
|
3122
3224
|
return 0
|
|
3123
3225
|
|
|
3124
3226
|
# Get instance name from injected session or target
|
|
@@ -3135,10 +3237,10 @@ def cmd_start(argv: list[str]) -> int:
|
|
|
3135
3237
|
if sid in positions and not positions[sid].get('enabled', False)
|
|
3136
3238
|
]
|
|
3137
3239
|
|
|
3138
|
-
print("Task tool running - you must provide an alias")
|
|
3139
|
-
print("Use: hcom start --_hcom_sender {alias}")
|
|
3240
|
+
print("Task tool running - you must provide an alias", file=sys.stderr)
|
|
3241
|
+
print("Use: hcom start --_hcom_sender {alias}", file=sys.stderr)
|
|
3140
3242
|
if subagent_ids:
|
|
3141
|
-
print(f"Choose from one of these valid aliases: {', '.join(subagent_ids)}")
|
|
3243
|
+
print(f"Choose from one of these valid aliases: {', '.join(subagent_ids)}", file=sys.stderr)
|
|
3142
3244
|
return 1
|
|
3143
3245
|
|
|
3144
3246
|
# Create instance if it doesn't exist (opt-in for vanilla instances)
|
|
@@ -3156,10 +3258,10 @@ def cmd_start(argv: list[str]) -> int:
|
|
|
3156
3258
|
# Check if background instance has exited permanently
|
|
3157
3259
|
if existing_data.get('session_ended') and existing_data.get('background'):
|
|
3158
3260
|
session = existing_data.get('session_id', '')
|
|
3159
|
-
print(f"Cannot start {instance_name}: headless instance has exited permanently")
|
|
3160
|
-
print(f"Headless instances terminate when stopped and cannot be restarted")
|
|
3261
|
+
print(f"Cannot start {instance_name}: headless instance has exited permanently", file=sys.stderr)
|
|
3262
|
+
print(f"Headless instances terminate when stopped and cannot be restarted", file=sys.stderr)
|
|
3161
3263
|
if session:
|
|
3162
|
-
print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
|
|
3264
|
+
print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}", file=sys.stderr)
|
|
3163
3265
|
return 1
|
|
3164
3266
|
|
|
3165
3267
|
# Re-enabling existing instance
|
|
@@ -3402,7 +3504,7 @@ def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> i
|
|
|
3402
3504
|
|
|
3403
3505
|
# First non-flag argument is the message
|
|
3404
3506
|
if argv:
|
|
3405
|
-
message = argv[0]
|
|
3507
|
+
message = unescape_bash(argv[0])
|
|
3406
3508
|
|
|
3407
3509
|
# Check message is provided
|
|
3408
3510
|
if not message:
|
|
@@ -3460,9 +3562,10 @@ def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> i
|
|
|
3460
3562
|
initialize_instance_in_position_file(sender_name, session_id)
|
|
3461
3563
|
instance_data = load_instance_position(sender_name)
|
|
3462
3564
|
|
|
3463
|
-
# Guard: If
|
|
3565
|
+
# Guard: If in subagent context, subagent MUST provide --_hcom_sender
|
|
3464
3566
|
if in_subagent_context(instance_data):
|
|
3465
|
-
# Get
|
|
3567
|
+
# Get only enabled subagents (active, can send messages)
|
|
3568
|
+
active_list = instance_data.get('current_subagents', [])
|
|
3466
3569
|
positions = load_all_positions()
|
|
3467
3570
|
subagent_ids = [name for name in positions if name.startswith(f"{sender_name}_")]
|
|
3468
3571
|
|
|
@@ -3484,12 +3587,6 @@ def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> i
|
|
|
3484
3587
|
print(format_error("HCOM not started for this instance. To send a message first run: 'hcom start' then use hcom send"), file=sys.stderr)
|
|
3485
3588
|
return 1
|
|
3486
3589
|
|
|
3487
|
-
# Handle "done" command - subagent finished work, wait for messages (control command)
|
|
3488
|
-
if message == "done" and subagent_id:
|
|
3489
|
-
# Control command - don't write to log, PostToolUse will handle polling
|
|
3490
|
-
print(f"Subagent {subagent_id}: Waiting for messages...", file=sys.stderr)
|
|
3491
|
-
return 0
|
|
3492
|
-
|
|
3493
3590
|
# Set status to active for subagents (identity confirmed, enabled verified)
|
|
3494
3591
|
if subagent_id:
|
|
3495
3592
|
set_status(subagent_id, 'active', 'send')
|
|
@@ -3560,6 +3657,34 @@ def send_cli(message: str, quiet: bool = False) -> int:
|
|
|
3560
3657
|
"""Force CLI sender (skip outbox, use config sender name)"""
|
|
3561
3658
|
return cmd_send([message], force_cli=True, quiet=quiet)
|
|
3562
3659
|
|
|
3660
|
+
def cmd_done(argv: list[str]) -> int:
|
|
3661
|
+
"""Signal subagent completion: hcom done [--_hcom_sender ID]
|
|
3662
|
+
Control command used by subagents to signal they've finished work
|
|
3663
|
+
and are ready to receive messages.
|
|
3664
|
+
"""
|
|
3665
|
+
subagent_id = None
|
|
3666
|
+
if '--_hcom_sender' in argv:
|
|
3667
|
+
idx = argv.index('--_hcom_sender')
|
|
3668
|
+
if idx + 1 < len(argv):
|
|
3669
|
+
subagent_id = argv[idx + 1]
|
|
3670
|
+
|
|
3671
|
+
if not subagent_id:
|
|
3672
|
+
print(format_error("hcom done requires --_hcom_sender flag"), file=sys.stderr)
|
|
3673
|
+
return 1
|
|
3674
|
+
|
|
3675
|
+
instance_data = load_instance_position(subagent_id)
|
|
3676
|
+
if not instance_data:
|
|
3677
|
+
print(format_error(f"'{subagent_id}' not found"), file=sys.stderr)
|
|
3678
|
+
return 1
|
|
3679
|
+
|
|
3680
|
+
if not instance_data.get('enabled', False):
|
|
3681
|
+
print(format_error(f"HCOM not started for '{subagent_id}'"), file=sys.stderr)
|
|
3682
|
+
return 1
|
|
3683
|
+
|
|
3684
|
+
# PostToolUse will handle the actual polling loop
|
|
3685
|
+
print(f"{subagent_id}: waiting for messages...")
|
|
3686
|
+
return 0
|
|
3687
|
+
|
|
3563
3688
|
# ==================== Hook Helpers ====================
|
|
3564
3689
|
|
|
3565
3690
|
def format_subagent_hcom_instructions(alias: str) -> str:
|
|
@@ -3577,21 +3702,22 @@ Replace all mentions of "hcom" below with this command.
|
|
|
3577
3702
|
|
|
3578
3703
|
return f"""{command_notice}[HCOM INFORMATION]
|
|
3579
3704
|
Your HCOM alias is: {alias}
|
|
3580
|
-
HCOM is a communication tool.
|
|
3705
|
+
HCOM is a communication tool. You are now connected/started.
|
|
3581
3706
|
|
|
3582
3707
|
- To Send a message, run:
|
|
3583
3708
|
hcom send 'your message' --_hcom_sender {alias}
|
|
3584
3709
|
(use '@alias' for direct messages)
|
|
3585
3710
|
|
|
3586
|
-
-
|
|
3587
|
-
There is no
|
|
3711
|
+
- You receive messages automatically via bash feedback or hooks.
|
|
3712
|
+
There is no proactive checking for messages needed.
|
|
3588
3713
|
|
|
3589
|
-
- When finished working, always run:
|
|
3590
|
-
hcom
|
|
3714
|
+
- When finished working or waiting on a reply, always run:
|
|
3715
|
+
hcom done --_hcom_sender {alias}
|
|
3716
|
+
(you will be automatically alerted of any new messages immediately)
|
|
3591
3717
|
|
|
3592
3718
|
- {{"decision": "block"}} text is normal operation
|
|
3593
3719
|
- Prioritize @{SENDER} over other participants
|
|
3594
|
-
- First action: Announce your online presence
|
|
3720
|
+
- First action: Announce your online presence in hcom chat
|
|
3595
3721
|
------"""
|
|
3596
3722
|
|
|
3597
3723
|
def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
|
|
@@ -3659,11 +3785,13 @@ def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -
|
|
|
3659
3785
|
|
|
3660
3786
|
def is_safe_hcom_command(command: str) -> bool:
|
|
3661
3787
|
"""Security check: verify ALL parts of chained command are hcom commands"""
|
|
3662
|
-
# Strip quoted strings, split on
|
|
3663
|
-
cmd = re.sub(r'''(["'])(?:(?=(\\?))\2.)*?\1''', '', command)
|
|
3664
|
-
parts = [p.strip() for p in re.split(r'\s*(
|
|
3788
|
+
# Strip quoted strings, split on &&/||/;/|, check all parts match hcom pattern
|
|
3789
|
+
cmd = re.sub(r'''(["'])(?:(?=(\\?))\2.)*?\1''', '', command, flags=re.DOTALL)
|
|
3790
|
+
parts = [p.strip() for p in re.split(r'\s*(?:&&|\|\||;|\|)\s*', cmd) if p.strip()]
|
|
3665
3791
|
return bool(parts) and all(HCOM_COMMAND_PATTERN.match(p) for p in parts)
|
|
3666
3792
|
|
|
3793
|
+
|
|
3794
|
+
|
|
3667
3795
|
def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
3668
3796
|
"""Handle PreToolUse hook - check force_closed, inject session_id, inject subagent identity"""
|
|
3669
3797
|
instance_data = load_instance_position(instance_name)
|
|
@@ -4022,7 +4150,7 @@ def handle_subagent_stop(hook_data: dict[str, Any], parent_name: str, updates: d
|
|
|
4022
4150
|
|
|
4023
4151
|
# reminder to run 'done' command
|
|
4024
4152
|
reminder = (
|
|
4025
|
-
"[HCOM]: You MUST run 'hcom
|
|
4153
|
+
"[HCOM]: You MUST run 'hcom done --_hcom_sender <your_alias>' "
|
|
4026
4154
|
"This allows you to receive messages and prevents timeout. "
|
|
4027
4155
|
"Run this command NOW."
|
|
4028
4156
|
)
|
|
@@ -4289,59 +4417,67 @@ def handle_posttooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
|
4289
4417
|
sys.exit(0)
|
|
4290
4418
|
|
|
4291
4419
|
# Detect subagent 'done' command - subagent finished work, waiting for messages
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
set_status(subagent_id, 'waiting', 'timeout')
|
|
4420
|
+
subagent_id = None
|
|
4421
|
+
done_pattern = re.compile(r'((?:uvx\s+)?hcom|python3?\s+-m\s+hcom|(?:python3?\s+)?\S*hcom\.pyz?)\s+done\b')
|
|
4422
|
+
if done_pattern.search(command) and '--_hcom_sender' in command:
|
|
4423
|
+
try:
|
|
4424
|
+
tokens = shlex.split(command)
|
|
4425
|
+
idx = tokens.index('--_hcom_sender')
|
|
4426
|
+
if idx + 1 < len(tokens):
|
|
4427
|
+
subagent_id = tokens[idx + 1]
|
|
4428
|
+
except (ValueError, IndexError):
|
|
4429
|
+
pass
|
|
4430
|
+
|
|
4431
|
+
if subagent_id:
|
|
4432
|
+
# Check if disabled - exit immediately
|
|
4433
|
+
instance_data_sub = load_instance_position(subagent_id)
|
|
4434
|
+
if not instance_data_sub or not instance_data_sub.get('enabled', False):
|
|
4435
|
+
sys.exit(0)
|
|
4436
|
+
|
|
4437
|
+
# Update heartbeat and status to mark as waiting
|
|
4438
|
+
update_instance_position(subagent_id, {'last_stop': time.time()})
|
|
4439
|
+
set_status(subagent_id, 'waiting')
|
|
4440
|
+
|
|
4441
|
+
# Run polling loop with KNOWN identity
|
|
4442
|
+
timeout = get_config().subagent_timeout
|
|
4443
|
+
start = time.time()
|
|
4444
|
+
|
|
4445
|
+
while time.time() - start < timeout:
|
|
4446
|
+
# Check disabled on each iteration
|
|
4447
|
+
instance_data_sub = load_instance_position(subagent_id)
|
|
4448
|
+
if not instance_data_sub or not instance_data_sub.get('enabled', False):
|
|
4449
|
+
sys.exit(0)
|
|
4450
|
+
|
|
4451
|
+
messages = get_unread_messages(subagent_id, update_position=False)
|
|
4452
|
+
|
|
4453
|
+
if messages:
|
|
4454
|
+
# Targeted delivery to THIS subagent only
|
|
4455
|
+
formatted = format_hook_messages(messages, subagent_id)
|
|
4456
|
+
# Mark as read and set delivery status
|
|
4457
|
+
result = parse_log_messages(hcom_path(LOG_FILE),
|
|
4458
|
+
instance_data_sub.get('pos', 0))
|
|
4459
|
+
update_instance_position(subagent_id, {'pos': result.end_position})
|
|
4460
|
+
set_status(subagent_id, 'delivered', messages[-1]['from'])
|
|
4461
|
+
# Use additionalContext only (no decision:block)
|
|
4462
|
+
output = {
|
|
4463
|
+
"hookSpecificOutput": {
|
|
4464
|
+
"hookEventName": "PostToolUse",
|
|
4465
|
+
"additionalContext": formatted
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
4342
4469
|
sys.exit(0)
|
|
4343
4470
|
|
|
4344
|
-
|
|
4471
|
+
# Update heartbeat during wait
|
|
4472
|
+
update_instance_position(subagent_id, {'last_stop': time.time()})
|
|
4473
|
+
time.sleep(1)
|
|
4474
|
+
|
|
4475
|
+
# Timeout - no messages, disable this subagent
|
|
4476
|
+
update_instance_position(subagent_id, {'enabled': False})
|
|
4477
|
+
set_status(subagent_id, 'waiting', 'timeout')
|
|
4478
|
+
sys.exit(0)
|
|
4479
|
+
|
|
4480
|
+
# All exits above handled - this code unreachable but keep for consistency TODO: remove
|
|
4345
4481
|
sys.exit(0)
|
|
4346
4482
|
|
|
4347
4483
|
# Parent context - show launch context and bootstrap
|
|
@@ -4386,7 +4522,7 @@ def handle_posttooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
|
4386
4522
|
message = (
|
|
4387
4523
|
"[HCOM NOTIFICATION]\n"
|
|
4388
4524
|
"Your HCOM connection has been stopped by an external command.\n"
|
|
4389
|
-
"You will no longer receive messages
|
|
4525
|
+
"You will no longer receive messages. Stop your current work immediately."
|
|
4390
4526
|
)
|
|
4391
4527
|
output = {
|
|
4392
4528
|
"hookSpecificOutput": {
|
|
@@ -4490,10 +4626,12 @@ def handle_hook(hook_type: str) -> None:
|
|
|
4490
4626
|
|
|
4491
4627
|
# Skip enabled check for UserPromptSubmit when bootstrap needs to be shown
|
|
4492
4628
|
# (alias_announced=false means bootstrap hasn't been shown yet)
|
|
4629
|
+
# Skip enabled check for PostToolUse when launch context needs to be shown
|
|
4493
4630
|
# Skip enabled check for PostToolUse in subagent context (need to deliver subagent messages)
|
|
4494
4631
|
# Skip enabled check for SubagentStop (resolves to parent name, but runs for subagents)
|
|
4495
4632
|
skip_enabled_check = (
|
|
4496
4633
|
(hook_type == 'userpromptsubmit' and not instance_data.get('alias_announced', False)) or
|
|
4634
|
+
(hook_type == 'post' and not instance_data.get('launch_context_announced', False)) or
|
|
4497
4635
|
(hook_type == 'post' and in_subagent_context(instance_data)) or
|
|
4498
4636
|
(hook_type == 'subagent-stop')
|
|
4499
4637
|
)
|
|
@@ -4577,6 +4715,8 @@ def main(argv: list[str] | None = None) -> int | None:
|
|
|
4577
4715
|
return cmd_stop(argv[1:])
|
|
4578
4716
|
elif argv[0] == 'start':
|
|
4579
4717
|
return cmd_start(argv[1:])
|
|
4718
|
+
elif argv[0] == 'done':
|
|
4719
|
+
return cmd_done(argv[1:])
|
|
4580
4720
|
elif argv[0] == 'reset':
|
|
4581
4721
|
return cmd_reset(argv[1:])
|
|
4582
4722
|
elif argv[0].isdigit() or argv[0] == 'claude':
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
-
__version__ = "0.6.
|
|
8
|
+
__version__ = "0.6.1"
|
|
9
9
|
|
|
10
10
|
# ===== Core ANSI Codes =====
|
|
11
11
|
RESET = "\033[0m"
|
|
@@ -192,6 +192,7 @@ def parse_env_value(value: str) -> str:
|
|
|
192
192
|
inner = inner.replace('\\\\', '\x00')
|
|
193
193
|
inner = inner.replace('\\n', '\n')
|
|
194
194
|
inner = inner.replace('\\t', '\t')
|
|
195
|
+
inner = inner.replace('\\r', '\r')
|
|
195
196
|
inner = inner.replace('\\"', '"')
|
|
196
197
|
inner = inner.replace('\x00', '\\')
|
|
197
198
|
return inner
|
|
@@ -207,8 +208,16 @@ def format_env_value(value: str) -> str:
|
|
|
207
208
|
if not value:
|
|
208
209
|
return value
|
|
209
210
|
|
|
210
|
-
if
|
|
211
|
-
|
|
211
|
+
# Check if quoting needed for special characters
|
|
212
|
+
needs_quoting = any(c in value for c in ['\n', '\t', '"', "'", ' ', '\r'])
|
|
213
|
+
|
|
214
|
+
if needs_quoting:
|
|
215
|
+
# Use double quotes with proper escaping
|
|
216
|
+
escaped = value.replace('\\', '\\\\') # Escape backslashes first
|
|
217
|
+
escaped = escaped.replace('\n', '\\n') # Escape newlines
|
|
218
|
+
escaped = escaped.replace('\t', '\\t') # Escape tabs
|
|
219
|
+
escaped = escaped.replace('\r', '\\r') # Escape carriage returns
|
|
220
|
+
escaped = escaped.replace('"', '\\"') # Escape double quotes
|
|
212
221
|
return f'"{escaped}"'
|
|
213
222
|
|
|
214
223
|
return value
|
|
@@ -555,6 +564,167 @@ def resolve_claude_args(
|
|
|
555
564
|
return _parse_tokens([], "none")
|
|
556
565
|
|
|
557
566
|
|
|
567
|
+
def merge_claude_args(env_spec: ClaudeArgsSpec, cli_spec: ClaudeArgsSpec) -> ClaudeArgsSpec:
|
|
568
|
+
"""Merge env and CLI specs with smart precedence rules.
|
|
569
|
+
|
|
570
|
+
Rules:
|
|
571
|
+
1. If CLI has positional args, they REPLACE all env positionals
|
|
572
|
+
- Empty string positional ("") explicitly deletes env positionals
|
|
573
|
+
- No CLI positional means inherit env positionals
|
|
574
|
+
2. CLI flags override env flags (per-flag precedence)
|
|
575
|
+
3. Duplicate boolean flags are deduped
|
|
576
|
+
4. System prompts handled separately via system_entries
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
env_spec: Parsed spec from HCOM_CLAUDE_ARGS env
|
|
580
|
+
cli_spec: Parsed spec from CLI forwarded args
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
Merged ClaudeArgsSpec with CLI taking precedence
|
|
584
|
+
"""
|
|
585
|
+
# Handle positionals: CLI replaces env (if present), else inherit env
|
|
586
|
+
if cli_spec.positional_tokens:
|
|
587
|
+
# Check for empty string deletion marker
|
|
588
|
+
if cli_spec.positional_tokens == ("",):
|
|
589
|
+
final_positionals = []
|
|
590
|
+
else:
|
|
591
|
+
final_positionals = list(cli_spec.positional_tokens)
|
|
592
|
+
else:
|
|
593
|
+
# No CLI positional → inherit env positional
|
|
594
|
+
final_positionals = list(env_spec.positional_tokens)
|
|
595
|
+
|
|
596
|
+
# Extract flag names from CLI to know what to override
|
|
597
|
+
cli_flag_names = _extract_flag_names_from_tokens(cli_spec.clean_tokens)
|
|
598
|
+
|
|
599
|
+
# Filter out positionals from env and CLI clean_tokens to avoid duplication
|
|
600
|
+
env_positional_set = set(env_spec.positional_tokens)
|
|
601
|
+
cli_positional_set = set(cli_spec.positional_tokens)
|
|
602
|
+
|
|
603
|
+
# Build merged tokens: env flags (not overridden, not positionals) + CLI flags (not positionals)
|
|
604
|
+
merged_tokens = []
|
|
605
|
+
skip_next = False
|
|
606
|
+
|
|
607
|
+
for i, token in enumerate(env_spec.clean_tokens):
|
|
608
|
+
if skip_next:
|
|
609
|
+
skip_next = False
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
# Skip positionals (will be added explicitly later)
|
|
613
|
+
if token in env_positional_set:
|
|
614
|
+
continue
|
|
615
|
+
|
|
616
|
+
# Check if this is a flag that CLI overrides
|
|
617
|
+
flag_name = _extract_flag_name_from_token(token)
|
|
618
|
+
if flag_name and flag_name in cli_flag_names:
|
|
619
|
+
# CLI overrides this flag, skip env version
|
|
620
|
+
# Check if next token is the value (space-separated syntax)
|
|
621
|
+
if '=' not in token and i + 1 < len(env_spec.clean_tokens):
|
|
622
|
+
next_token = env_spec.clean_tokens[i + 1]
|
|
623
|
+
# Only skip next if it's not a known flag (it's the value)
|
|
624
|
+
if not _looks_like_new_flag(next_token.lower()):
|
|
625
|
+
skip_next = True
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
merged_tokens.append(token)
|
|
629
|
+
|
|
630
|
+
# Append all CLI tokens (excluding positionals)
|
|
631
|
+
for token in cli_spec.clean_tokens:
|
|
632
|
+
if token not in cli_positional_set:
|
|
633
|
+
merged_tokens.append(token)
|
|
634
|
+
|
|
635
|
+
# Deduplicate boolean flags
|
|
636
|
+
merged_tokens = _deduplicate_boolean_flags(merged_tokens)
|
|
637
|
+
|
|
638
|
+
# Handle system prompts: CLI wins if present, else env
|
|
639
|
+
if cli_spec.system_entries:
|
|
640
|
+
system_entries = cli_spec.system_entries
|
|
641
|
+
else:
|
|
642
|
+
system_entries = env_spec.system_entries
|
|
643
|
+
|
|
644
|
+
# Rebuild spec from merged tokens
|
|
645
|
+
# Need to combine tokens and positionals properly
|
|
646
|
+
combined_tokens = list(merged_tokens)
|
|
647
|
+
|
|
648
|
+
# Insert positionals at correct position
|
|
649
|
+
# Find where positionals should go (after flags, before --)
|
|
650
|
+
insert_idx = len(combined_tokens)
|
|
651
|
+
try:
|
|
652
|
+
dash_idx = combined_tokens.index('--')
|
|
653
|
+
insert_idx = dash_idx
|
|
654
|
+
except ValueError:
|
|
655
|
+
pass
|
|
656
|
+
|
|
657
|
+
# Insert positionals before -- (or at end)
|
|
658
|
+
for pos in reversed(final_positionals):
|
|
659
|
+
combined_tokens.insert(insert_idx, pos)
|
|
660
|
+
|
|
661
|
+
# Add system prompts back
|
|
662
|
+
for flag, value in system_entries:
|
|
663
|
+
combined_tokens.extend([flag, value])
|
|
664
|
+
|
|
665
|
+
# Re-parse to get proper ClaudeArgsSpec with all fields populated
|
|
666
|
+
return _parse_tokens(combined_tokens, "cli")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _extract_flag_names_from_tokens(tokens: Sequence[str]) -> set[str]:
|
|
670
|
+
"""Extract normalized flag names from token list.
|
|
671
|
+
|
|
672
|
+
Returns set of lowercase flag names (without values).
|
|
673
|
+
Examples: '--model' → '--model', '--model=opus' → '--model'
|
|
674
|
+
"""
|
|
675
|
+
flag_names = set()
|
|
676
|
+
for token in tokens:
|
|
677
|
+
flag_name = _extract_flag_name_from_token(token)
|
|
678
|
+
if flag_name:
|
|
679
|
+
flag_names.add(flag_name)
|
|
680
|
+
return flag_names
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _extract_flag_name_from_token(token: str) -> str | None:
|
|
684
|
+
"""Extract flag name from a token.
|
|
685
|
+
|
|
686
|
+
Examples:
|
|
687
|
+
'--model' → '--model'
|
|
688
|
+
'--model=opus' → '--model'
|
|
689
|
+
'-p' → '-p'
|
|
690
|
+
'value' → None
|
|
691
|
+
"""
|
|
692
|
+
token_lower = token.lower()
|
|
693
|
+
|
|
694
|
+
# Check if starts with - or --
|
|
695
|
+
if not token_lower.startswith('-'):
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
# Extract name (before = if present)
|
|
699
|
+
if '=' in token_lower:
|
|
700
|
+
return token_lower.split('=')[0]
|
|
701
|
+
|
|
702
|
+
return token_lower
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _deduplicate_boolean_flags(tokens: Sequence[str]) -> list[str]:
|
|
706
|
+
"""Remove duplicate boolean flags, keeping first occurrence.
|
|
707
|
+
|
|
708
|
+
Only deduplicates known boolean flags like --verbose, -p, etc.
|
|
709
|
+
Unknown flags and value flags are left as-is (Claude CLI handles them).
|
|
710
|
+
"""
|
|
711
|
+
seen_flags = set()
|
|
712
|
+
result = []
|
|
713
|
+
|
|
714
|
+
for token in tokens:
|
|
715
|
+
token_lower = token.lower()
|
|
716
|
+
|
|
717
|
+
# Check if this is a known boolean flag
|
|
718
|
+
if token_lower in _BOOLEAN_FLAGS or token_lower in _BACKGROUND_SWITCHES:
|
|
719
|
+
if token_lower in seen_flags:
|
|
720
|
+
continue # Skip duplicate
|
|
721
|
+
seen_flags.add(token_lower)
|
|
722
|
+
|
|
723
|
+
result.append(token)
|
|
724
|
+
|
|
725
|
+
return result
|
|
726
|
+
|
|
727
|
+
|
|
558
728
|
def merge_system_prompts(
|
|
559
729
|
user_append: str | None,
|
|
560
730
|
user_system: str | None,
|
|
@@ -1342,6 +1342,18 @@ class HcomTUI:
|
|
|
1342
1342
|
visible_end = min(visible_start + instance_rows, total_instances)
|
|
1343
1343
|
visible_instances = sorted_instances[visible_start:visible_end]
|
|
1344
1344
|
|
|
1345
|
+
# Calculate dynamic name column width based on actual names
|
|
1346
|
+
max_instance_name_len = max((len(name) for name, _ in sorted_instances), default=0)
|
|
1347
|
+
# Check if any instance has background marker
|
|
1348
|
+
has_background = any(info.get('data', {}).get('background', False) for _, info in sorted_instances)
|
|
1349
|
+
bg_marker_len = 11 if has_background else 0 # " [headless]"
|
|
1350
|
+
# Add space for state symbol on cursor row (2 chars: " +")
|
|
1351
|
+
name_col_width = max_instance_name_len + bg_marker_len + 2
|
|
1352
|
+
# Set bounds: min 20, max based on terminal width
|
|
1353
|
+
# Reserve: 2 (icon) + 10 (age) + 2 (sep) + 30 (desc min) = 44
|
|
1354
|
+
max_name_width = max(20, width - 44)
|
|
1355
|
+
name_col_width = max(20, min(name_col_width, max_name_width))
|
|
1356
|
+
|
|
1345
1357
|
# Render instances - compact one-line format
|
|
1346
1358
|
for i, (name, info) in enumerate(visible_instances):
|
|
1347
1359
|
absolute_idx = visible_start + i
|
|
@@ -1384,9 +1396,9 @@ class HcomTUI:
|
|
|
1384
1396
|
if 0 < remaining < 60:
|
|
1385
1397
|
timeout_marker = f" {FG_YELLOW}⏱ {int(remaining)}s{RESET}"
|
|
1386
1398
|
|
|
1387
|
-
# Smart truncate name to fit in
|
|
1388
|
-
# Available:
|
|
1389
|
-
max_name_len =
|
|
1399
|
+
# Smart truncate name to fit in dynamic column width
|
|
1400
|
+
# Available: name_col_width - bg_marker_len - (2 for " +/-" on cursor row)
|
|
1401
|
+
max_name_len = name_col_width - bg_marker_visible_len - 2 # Leave 2 chars for " +" or " -"
|
|
1390
1402
|
display_name = smart_truncate_name(name, max_name_len)
|
|
1391
1403
|
|
|
1392
1404
|
# State indicator (only on cursor row)
|
|
@@ -1401,13 +1413,13 @@ class HcomTUI:
|
|
|
1401
1413
|
else:
|
|
1402
1414
|
state_symbol = "-"
|
|
1403
1415
|
state_color = color
|
|
1404
|
-
# Format: name [headless] +/-
|
|
1416
|
+
# Format: name [headless] +/-
|
|
1405
1417
|
name_with_marker = f"{display_name}{bg_marker_text} {state_symbol}"
|
|
1406
|
-
name_padded = ansi_ljust(name_with_marker,
|
|
1418
|
+
name_padded = ansi_ljust(name_with_marker, name_col_width)
|
|
1407
1419
|
else:
|
|
1408
|
-
# Format: name [headless]
|
|
1420
|
+
# Format: name [headless]
|
|
1409
1421
|
name_with_marker = f"{display_name}{bg_marker_text}"
|
|
1410
|
-
name_padded = ansi_ljust(name_with_marker,
|
|
1422
|
+
name_padded = ansi_ljust(name_with_marker, name_col_width)
|
|
1411
1423
|
|
|
1412
1424
|
# Description separator - only show if description exists
|
|
1413
1425
|
desc_sep = ": " if display_text else ""
|
|
@@ -1466,9 +1478,12 @@ class HcomTUI:
|
|
|
1466
1478
|
if self.messages:
|
|
1467
1479
|
all_wrapped_lines = []
|
|
1468
1480
|
|
|
1469
|
-
# Find longest sender name for alignment
|
|
1470
|
-
max_sender_len = max(len(sender) for _, sender, _ in self.messages)
|
|
1471
|
-
|
|
1481
|
+
# Find longest sender name for alignment - dynamic with reasonable max
|
|
1482
|
+
max_sender_len = max((len(sender) for _, sender, _ in self.messages), default=12)
|
|
1483
|
+
# Reserve: 5 (time) + 1 (space) + sender + 1 (space) + 50 (msg min) = 57 + sender
|
|
1484
|
+
# Only expand sender column when width > 69 to avoid jumpiness with narrow terminals
|
|
1485
|
+
max_sender_width = max(12, width - 57) if width > 69 else 12
|
|
1486
|
+
max_sender_len = min(max_sender_len, max_sender_width)
|
|
1472
1487
|
|
|
1473
1488
|
for time_str, sender, message in self.messages:
|
|
1474
1489
|
# Format timestamp
|
|
@@ -1807,7 +1822,7 @@ class HcomTUI:
|
|
|
1807
1822
|
if field.value:
|
|
1808
1823
|
# Has value - color only if different from default (normalize quotes)
|
|
1809
1824
|
field_value_normalized = str(field.value).strip().strip("'\"")
|
|
1810
|
-
default_normalized = default.strip().strip("'\"")
|
|
1825
|
+
default_normalized = str(default).strip().strip("'\"")
|
|
1811
1826
|
is_modified = field_value_normalized != default_normalized
|
|
1812
1827
|
color = value_color if is_modified else FG_WHITE
|
|
1813
1828
|
value_str = f"{color}{field.value}{RESET}"
|
|
@@ -1985,7 +2000,7 @@ class HcomTUI:
|
|
|
1985
2000
|
for field in hcom_fields:
|
|
1986
2001
|
if is_field_modified(field):
|
|
1987
2002
|
val = field.value or ""
|
|
1988
|
-
if
|
|
2003
|
+
if field.field_type == 'checkbox':
|
|
1989
2004
|
val_str = "true" if val == "true" else "false"
|
|
1990
2005
|
else:
|
|
1991
2006
|
val = str(val) if val else ""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|