hcom 0.3.0__tar.gz → 0.3.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.3.0
3
+ Version: 0.3.1
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
@@ -250,7 +250,7 @@ When running `hcom watch`, each instance shows its current state:
250
250
 
251
251
  hcom adds hooks to your project directory's `.claude/settings.local.json`:
252
252
 
253
- 1. **Sending**: Claude agents use `$HCOM send "message"` internally (you use `hcom send` from terminal or dashboard)
253
+ 1. **Sending**: Claude agents use `eval $HCOM send "message"` internally (you use `hcom send` from terminal or dashboard)
254
254
  2. **Receiving**: Other Claudes get notified via Stop hook or immediate delivery after sending
255
255
  3. **Waiting**: Stop hook keeps Claude in a waiting state for new messages
256
256
 
@@ -223,7 +223,7 @@ When running `hcom watch`, each instance shows its current state:
223
223
 
224
224
  hcom adds hooks to your project directory's `.claude/settings.local.json`:
225
225
 
226
- 1. **Sending**: Claude agents use `$HCOM send "message"` internally (you use `hcom send` from terminal or dashboard)
226
+ 1. **Sending**: Claude agents use `eval $HCOM send "message"` internally (you use `hcom send` from terminal or dashboard)
227
227
  2. **Receiving**: Other Claudes get notified via Stop hook or immediate delivery after sending
228
228
  3. **Waiting**: Stop hook keeps Claude in a waiting state for new messages
229
229
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hcom"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- hcom 0.3.0
3
+ hcom 0.3.1
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
 
@@ -1326,7 +1326,7 @@ def verify_hooks_installed(settings_path):
1326
1326
 
1327
1327
  # Check all hook types exist with HCOM commands (PostToolUse removed)
1328
1328
  hooks = settings.get('hooks', {})
1329
- for hook_type in ['SessionStart', 'PreToolUse', 'Stop', 'Notification']:
1329
+ for hook_type in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop', 'Notification']:
1330
1330
  if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
1331
1331
  for h in hooks.get(hook_type, [])):
1332
1332
  return False
@@ -1507,33 +1507,40 @@ def format_age(seconds: float) -> str:
1507
1507
  else:
1508
1508
  return f"{int(seconds/3600)}h"
1509
1509
 
1510
- def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str]:
1511
- """Get current status of instance. Returns (status_type, age_string)."""
1512
- # Returns: (display_category, formatted_age) - category for color, age for display
1510
+ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
1511
+ """Get current status of instance. Returns (status_type, age_string, description)."""
1512
+ # Returns: (display_category, formatted_age, status_description)
1513
1513
  now = int(time.time())
1514
1514
 
1515
1515
  # Check if killed
1516
1516
  if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
1517
- return "inactive", ""
1517
+ return "inactive", "", "killed"
1518
1518
 
1519
1519
  # Get last known status
1520
1520
  last_status = pos_data.get('last_status', '')
1521
1521
  last_status_time = pos_data.get('last_status_time', 0)
1522
+ last_context = pos_data.get('last_status_context', '')
1522
1523
 
1523
1524
  if not last_status or not last_status_time:
1524
- return "unknown", ""
1525
+ return "unknown", "", "unknown"
1525
1526
 
1526
- # Get display category from STATUS_INFO
1527
- display_status, _ = STATUS_INFO.get(last_status, ('unknown', ''))
1527
+ # Get display category and description template from STATUS_INFO
1528
+ display_status, desc_template = STATUS_INFO.get(last_status, ('unknown', 'unknown'))
1528
1529
 
1529
1530
  # Check timeout
1530
1531
  age = now - last_status_time
1531
1532
  timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1532
1533
  if age > timeout:
1533
- return "inactive", ""
1534
+ return "inactive", "", "timeout"
1535
+
1536
+ # Format description with context if template has {}
1537
+ if '{}' in desc_template and last_context:
1538
+ status_desc = desc_template.format(last_context)
1539
+ else:
1540
+ status_desc = desc_template
1534
1541
 
1535
1542
  status_suffix = " (bg)" if pos_data.get('background') else ""
1536
- return display_status, f"({format_age(age)}){status_suffix}"
1543
+ return display_status, f"({format_age(age)}){status_suffix}", status_desc
1537
1544
 
1538
1545
  def get_status_block(status_type: str) -> str:
1539
1546
  """Get colored status block for a status type"""
@@ -1607,20 +1614,9 @@ def show_instances_by_directory():
1607
1614
  for directory, instances in directories.items():
1608
1615
  print(f" {directory}")
1609
1616
  for instance_name, pos_data in instances:
1610
- status_type, age = get_instance_status(pos_data)
1617
+ status_type, age, status_desc = get_instance_status(pos_data)
1611
1618
  status_block = get_status_block(status_type)
1612
1619
 
1613
- # Format status description using STATUS_INFO and context
1614
- last_status = pos_data.get('last_status', '')
1615
- last_context = pos_data.get('last_status_context', '')
1616
- _, desc_template = STATUS_INFO.get(last_status, ('unknown', ''))
1617
-
1618
- # Format description with context if template has {}
1619
- if '{}' in desc_template and last_context:
1620
- status_desc = desc_template.format(last_context)
1621
- else:
1622
- status_desc = desc_template
1623
-
1624
1620
  print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_desc} {age}{RESET}")
1625
1621
  print()
1626
1622
  else:
@@ -1664,7 +1660,7 @@ def get_status_summary():
1664
1660
  status_counts = {status: 0 for status in STATUS_MAP.keys()}
1665
1661
 
1666
1662
  for _, pos_data in positions.items():
1667
- status_type, _ = get_instance_status(pos_data)
1663
+ status_type, _, _ = get_instance_status(pos_data)
1668
1664
  if status_type in status_counts:
1669
1665
  status_counts[status_type] += 1
1670
1666
 
@@ -2209,7 +2205,7 @@ def cmd_watch(*args):
2209
2205
  status_counts = {}
2210
2206
 
2211
2207
  for name, data in positions.items():
2212
- status, age = get_instance_status(data)
2208
+ status, age, _ = get_instance_status(data)
2213
2209
  instances[name] = {
2214
2210
  "status": status,
2215
2211
  "age": age.strip() if age else "",
@@ -2497,7 +2493,7 @@ def cmd_kill(*args):
2497
2493
 
2498
2494
  killed_count = 0
2499
2495
  for target_name, target_data in targets:
2500
- status, age = get_instance_status(target_data)
2496
+ status, age, _ = get_instance_status(target_data)
2501
2497
  instance_type = "background" if target_data.get('background') else "foreground"
2502
2498
 
2503
2499
  pid = int(target_data['pid'])
@@ -2609,8 +2605,10 @@ def cmd_send(message):
2609
2605
  try:
2610
2606
  positions = load_all_positions()
2611
2607
  all_instances = list(positions.keys())
2608
+ sender_name = get_config_value('sender_name', 'bigboss')
2609
+ all_names = all_instances + [sender_name]
2612
2610
  unmatched = [m for m in mentions
2613
- if not any(name.lower().startswith(m.lower()) for name in all_instances)]
2611
+ if not any(name.lower().startswith(m.lower()) for name in all_names)]
2614
2612
  if unmatched:
2615
2613
  print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
2616
2614
  except Exception:
@@ -2657,7 +2655,7 @@ def cmd_send(message):
2657
2655
  def cmd_resume_merge(alias: str) -> int:
2658
2656
  """Resume/merge current instance into an existing instance by alias.
2659
2657
 
2660
- INTERNAL COMMAND: Only called via '$HCOM send --resume alias' during implicit resume workflow.
2658
+ INTERNAL COMMAND: Only called via 'eval $HCOM send --resume alias' during implicit resume workflow.
2661
2659
  Not meant for direct CLI usage.
2662
2660
  """
2663
2661
  # Get current instance name via launch_id mapping (same mechanism as cmd_send)
@@ -2719,8 +2717,6 @@ def format_hook_messages(messages, instance_name):
2719
2717
 
2720
2718
  def init_hook_context(hook_data, hook_type=None):
2721
2719
  """Initialize instance context - shared by post/stop/notify hooks"""
2722
- import time
2723
-
2724
2720
  session_id = hook_data.get('session_id', '')
2725
2721
  transcript_path = hook_data.get('transcript_path', '')
2726
2722
  prefix = os.environ.get('HCOM_PREFIX')
@@ -2775,7 +2771,6 @@ def init_hook_context(hook_data, hook_type=None):
2775
2771
  )
2776
2772
  if should_create_instance:
2777
2773
  initialize_instance_in_position_file(instance_name, session_id)
2778
- existing_data = load_instance_position(instance_name) if should_create_instance else {}
2779
2774
 
2780
2775
  # Prepare updates
2781
2776
  updates: dict[str, Any] = {
@@ -2801,7 +2796,7 @@ def init_hook_context(hook_data, hook_type=None):
2801
2796
 
2802
2797
  # Return flags indicating resume state
2803
2798
  is_resume_match = merged_state is not None
2804
- return instance_name, updates, existing_data, is_resume_match, is_new_instance
2799
+ return instance_name, updates, is_resume_match, is_new_instance
2805
2800
 
2806
2801
  def handle_pretooluse(hook_data, instance_name, updates):
2807
2802
  """Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
@@ -2810,17 +2805,15 @@ def handle_pretooluse(hook_data, instance_name, updates):
2810
2805
  # Non-HCOM_SEND tools: record status (they'll run without permission check)
2811
2806
  set_status(instance_name, 'tool_pending', tool_name)
2812
2807
 
2813
- import time
2814
-
2815
2808
  # Handle HCOM commands in Bash
2816
2809
  if tool_name == 'Bash':
2817
2810
  command = hook_data.get('tool_input', {}).get('command', '')
2818
2811
  script_path = str(Path(__file__).resolve())
2819
2812
 
2820
- # === Auto-approve ALL '$HCOM send' commands (including --resume) ===
2813
+ # === Auto-approve ALL 'eval $HCOM send' commands (including --resume) ===
2821
2814
  # This includes:
2822
- # - $HCOM send "message" (normal messaging between instances)
2823
- # - $HCOM send --resume alias (resume/merge operation)
2815
+ # - eval $HCOM send "message" (normal messaging between instances)
2816
+ # - eval $HCOM send --resume alias (resume/merge operation)
2824
2817
  if ('$HCOM send' in command or
2825
2818
  'hcom send' in command or
2826
2819
  (script_path in command and ' send ' in command)):
@@ -2845,15 +2838,13 @@ def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
2845
2838
 
2846
2839
  def handle_stop(hook_data, instance_name, updates):
2847
2840
  """Handle Stop hook - poll for messages and deliver"""
2848
- import time as time_module
2849
-
2850
2841
  parent_pid = os.getppid()
2851
2842
  log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
2852
2843
  log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
2853
2844
 
2854
2845
 
2855
2846
  try:
2856
- entry_time = time_module.time()
2847
+ entry_time = time.time()
2857
2848
  updates['last_stop'] = entry_time
2858
2849
  timeout = get_config_value('wait_timeout', 1800)
2859
2850
  updates['wait_timeout'] = timeout
@@ -2864,30 +2855,29 @@ def handle_stop(hook_data, instance_name, updates):
2864
2855
  except Exception as e:
2865
2856
  log_hook_error(f'stop:update_instance_position({instance_name})', e)
2866
2857
 
2867
- start_time = time_module.time()
2858
+ start_time = time.time()
2868
2859
  log_hook_error(f'stop:start_time_pid_{os.getpid()}')
2869
2860
 
2870
2861
  try:
2871
2862
  loop_count = 0
2863
+ last_heartbeat = start_time
2872
2864
  # STEP 4: Actual polling loop - this IS the holding pattern
2873
- while time_module.time() - start_time < timeout:
2865
+ while time.time() - start_time < timeout:
2874
2866
  if loop_count == 0:
2875
- time_module.sleep(0.1) # Initial wait before first poll
2867
+ time.sleep(0.1) # Initial wait before first poll
2876
2868
  loop_count += 1
2877
2869
 
2878
2870
  # Check if parent is alive
2879
- if not IS_WINDOWS and os.getppid() == 1:
2880
- log_hook_error(f'stop:parent_died_pid_{os.getpid()}')
2881
- safe_exit_with_status(instance_name, EXIT_SUCCESS)
2882
-
2883
- parent_alive = is_parent_alive(parent_pid)
2884
- if not parent_alive:
2871
+ if not is_parent_alive(parent_pid):
2885
2872
  log_hook_error(f'stop:parent_not_alive_pid_{os.getpid()}')
2886
2873
  safe_exit_with_status(instance_name, EXIT_SUCCESS)
2887
2874
 
2888
- # Check if user input is pending - exit cleanly if so
2889
- user_input_signal = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
2890
- if user_input_signal.exists():
2875
+ # Load instance data once per poll (needed for messages and user input check)
2876
+ instance_data = load_instance_position(instance_name)
2877
+
2878
+ # Check if user input is pending - exit cleanly if recent input
2879
+ last_user_input = instance_data.get('last_user_input', 0)
2880
+ if time.time() - last_user_input < 0.2:
2891
2881
  log_hook_error(f'stop:user_input_pending_exiting_pid_{os.getpid()}')
2892
2882
  safe_exit_with_status(instance_name, EXIT_SUCCESS)
2893
2883
 
@@ -2902,14 +2892,16 @@ def handle_stop(hook_data, instance_name, updates):
2902
2892
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2903
2893
  sys.exit(EXIT_BLOCK)
2904
2894
 
2905
- # Update heartbeat
2906
- try:
2907
- update_instance_position(instance_name, {'last_stop': time_module.time()})
2908
- # log_hook_error(f'hb_pid_{os.getpid()}')
2909
- except Exception as e:
2910
- log_hook_error(f'stop:heartbeat_update({instance_name})', e)
2895
+ # Update heartbeat every 5 seconds instead of every poll
2896
+ now = time.time()
2897
+ if now - last_heartbeat >= 5.0:
2898
+ try:
2899
+ update_instance_position(instance_name, {'last_stop': now})
2900
+ last_heartbeat = now
2901
+ except Exception as e:
2902
+ log_hook_error(f'stop:heartbeat_update({instance_name})', e)
2911
2903
 
2912
- time_module.sleep(STOP_HOOK_POLL_INTERVAL)
2904
+ time.sleep(STOP_HOOK_POLL_INTERVAL)
2913
2905
 
2914
2906
  except Exception as loop_e:
2915
2907
  # Log polling loop errors but continue to cleanup
@@ -2931,22 +2923,12 @@ def handle_notify(hook_data, instance_name, updates):
2931
2923
 
2932
2924
  def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
2933
2925
  """Handle UserPromptSubmit hook - track when user sends messages"""
2934
- import time as time_module
2935
-
2936
2926
  # Update last user input timestamp
2937
- updates['last_user_input'] = time_module.time()
2927
+ updates['last_user_input'] = time.time()
2938
2928
  update_instance_position(instance_name, updates)
2939
2929
 
2940
- # Signal any polling Stop hook to exit cleanly before user input processed
2941
- signal_file = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
2942
- try:
2943
- log_hook_error(f'userpromptsubmit:signal_file_touched_pid_{os.getpid()}')
2944
- signal_file.touch()
2945
- time_module.sleep(0.15) # Give Stop hook time to detect and exit
2946
- log_hook_error(f'userpromptsubmit:signal_file_unlinked_pid_{os.getpid()}')
2947
- signal_file.unlink()
2948
- except (OSError, PermissionError) as e:
2949
- log_hook_error(f'userpromptsubmit:signal_file_error', e)
2930
+ # Wait for Stop hook to detect timestamp and exit (prevents api errors / race condition)
2931
+ time.sleep(0.15)
2950
2932
 
2951
2933
  send_cmd = build_send_command('your message')
2952
2934
  resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
@@ -3041,7 +3023,7 @@ def handle_hook(hook_type: str) -> None:
3041
3023
  session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
3042
3024
  log_hook_error(f'DEBUG: Hook {hook_type} called with session_id={session_id_short}')
3043
3025
 
3044
- instance_name, updates, _, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
3026
+ instance_name, updates, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
3045
3027
 
3046
3028
  match hook_type:
3047
3029
  case 'pre':
@@ -3091,7 +3073,7 @@ def main(argv=None):
3091
3073
 
3092
3074
  # HIDDEN COMMAND: --resume is only used internally by instances during resume workflow
3093
3075
  # Not meant for regular CLI usage. Primary usage:
3094
- # - From instances: $HCOM send "message" (instances send messages to each other)
3076
+ # - From instances: eval $HCOM send "message" (instances send messages to each other)
3095
3077
  # - From CLI: hcom send "message" (user/claude orchestrator sends to instances)
3096
3078
  if argv[2] == '--resume':
3097
3079
  if len(argv) < 4:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hcom
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -250,7 +250,7 @@ When running `hcom watch`, each instance shows its current state:
250
250
 
251
251
  hcom adds hooks to your project directory's `.claude/settings.local.json`:
252
252
 
253
- 1. **Sending**: Claude agents use `$HCOM send "message"` internally (you use `hcom send` from terminal or dashboard)
253
+ 1. **Sending**: Claude agents use `eval $HCOM send "message"` internally (you use `hcom send` from terminal or dashboard)
254
254
  2. **Receiving**: Other Claudes get notified via Stop hook or immediate delivery after sending
255
255
  3. **Waiting**: Stop hook keeps Claude in a waiting state for new messages
256
256
 
File without changes
File without changes
File without changes
File without changes