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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hcom
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Launch multiple Claude Code instances (terminal, headless, or subagents) that communicate together in real time via hooks.
5
5
  Author: aannoo
6
6
  License-Expression: MIT
@@ -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 for start)
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} available: {cmd}"
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 MUST run 'hcom help' first to understand context or if user asks about hcom)
1106
- - Claude code subagents launched with the Task tool can also connect to HCOM, just tell them to use 'hcom'.
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, 'claude help' for more tools)
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
- "inactive" - timed out, disconnected etc
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, env, cwd, command_str, background)
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 (or last max_msgs num)
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
- initial_pos = log_file.stat().st_size
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 using new resolve_claude_args
2506
- spec = resolve_claude_args(
2507
- forwarded if forwarded else None,
2508
- get_config().claude_args
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"No instance found for {instance_name}")
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 send done --_hcom_sender {sender_override}")
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 parent is in subagent context, subagent MUST provide --_hcom_sender
3565
+ # Guard: If in subagent context, subagent MUST provide --_hcom_sender
3464
3566
  if in_subagent_context(instance_data):
3465
- # Get list of active subagents for helpful error message
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
- - Messages are delivered automatically via bash feedback or hooks.
3587
- There is no way to proactively check or poll for messages yourself.
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 send done --_hcom_sender {alias}
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 to @{SENDER}
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 &&/||/;, check all parts match hcom pattern
3663
- cmd = re.sub(r'''(["'])(?:(?=(\\?))\2.)*?\1''', '', command)
3664
- parts = [p.strip() for p in re.split(r'\s*(?:&&|\|\||;)\s*', cmd) if p.strip()]
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 send done --_hcom_sender <your_alias>' "
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
- if 'done' in command and '--_hcom_sender' in command:
4293
- match = re.search(r'--_hcom_sender\s+(\S+)', command)
4294
- if match:
4295
- subagent_id = match.group(1)
4296
- # Check if disabled - exit immediately
4297
- instance_data_sub = load_instance_position(subagent_id)
4298
- if not instance_data_sub or not instance_data_sub.get('enabled', False):
4299
- sys.exit(0)
4300
-
4301
- # Update heartbeat and status to mark as waiting
4302
- update_instance_position(subagent_id, {'last_stop': time.time()})
4303
- set_status(subagent_id, 'waiting')
4304
-
4305
- # Run polling loop with KNOWN identity
4306
- timeout = get_config().subagent_timeout
4307
- start = time.time()
4308
-
4309
- while time.time() - start < timeout:
4310
- # Check disabled on each iteration
4311
- instance_data_sub = load_instance_position(subagent_id)
4312
- if not instance_data_sub or not instance_data_sub.get('enabled', False):
4313
- sys.exit(0)
4314
-
4315
- messages = get_unread_messages(subagent_id, update_position=False)
4316
-
4317
- if messages:
4318
- # Targeted delivery to THIS subagent only
4319
- formatted = format_hook_messages(messages, subagent_id)
4320
- # Mark as read and set delivery status
4321
- result = parse_log_messages(hcom_path(LOG_FILE),
4322
- instance_data_sub.get('pos', 0))
4323
- update_instance_position(subagent_id, {'pos': result.end_position})
4324
- set_status(subagent_id, 'delivered', messages[-1]['from'])
4325
- # Use additionalContext only (no decision:block)
4326
- output = {
4327
- "hookSpecificOutput": {
4328
- "hookEventName": "PostToolUse",
4329
- "additionalContext": formatted
4330
- }
4331
- }
4332
- print(json.dumps(output, ensure_ascii=False))
4333
- sys.exit(0)
4334
-
4335
- # Update heartbeat during wait
4336
- update_instance_position(subagent_id, {'last_stop': time.time()})
4337
- time.sleep(1)
4338
-
4339
- # Timeout - no messages, disable this subagent
4340
- update_instance_position(subagent_id, {'enabled': False})
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
- # All exits above handled - this code unreachable but keep for consistency
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 automatically. Stop your current work unless instructed otherwise."
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.0"
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 "'" in value:
211
- escaped = value.replace('\\', '\\\\').replace('"', '\\"')
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 24 chars including [headless] and state symbol
1388
- # Available: 24 - bg_marker_len - (2 for " +/-" on cursor row)
1389
- max_name_len = 22 - bg_marker_visible_len # Leave 2 chars for " +" or " -"
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] +/- (total 24 chars)
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, 24)
1418
+ name_padded = ansi_ljust(name_with_marker, name_col_width)
1407
1419
  else:
1408
- # Format: name [headless] (total 24 chars)
1420
+ # Format: name [headless]
1409
1421
  name_with_marker = f"{display_name}{bg_marker_text}"
1410
- name_padded = ansi_ljust(name_with_marker, 24)
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) if self.messages else 12
1471
- max_sender_len = min(max_sender_len, 12) # Cap at reasonable width
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 hasattr(field, 'type') and field.type == 'bool':
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 ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hcom
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Launch multiple Claude Code instances (terminal, headless, or subagents) that communicate together in real time via hooks.
5
5
  Author: aannoo
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes