hcom 0.1.7__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hcom might be problematic. Click here for more details.

hcom/__main__.py CHANGED
@@ -11,12 +11,14 @@ import tempfile
11
11
  import shutil
12
12
  import shlex
13
13
  import re
14
+ import subprocess
14
15
  import time
15
16
  import select
16
17
  import threading
17
18
  import platform
19
+ import random
18
20
  from pathlib import Path
19
- from datetime import datetime
21
+ from datetime import datetime, timedelta
20
22
 
21
23
  # ==================== Constants ====================
22
24
 
@@ -113,6 +115,50 @@ def atomic_write(filepath, content):
113
115
 
114
116
  os.replace(tmp.name, filepath)
115
117
 
118
+ def get_instance_file(instance_name):
119
+ """Get path to instance's position file"""
120
+ return get_hcom_dir() / "instances" / f"{instance_name}.json"
121
+
122
+ def load_instance_position(instance_name):
123
+ """Load position data for a single instance"""
124
+ instance_file = get_instance_file(instance_name)
125
+ if instance_file.exists():
126
+ try:
127
+ with open(instance_file, 'r') as f:
128
+ return json.load(f)
129
+ except (json.JSONDecodeError, FileNotFoundError):
130
+ pass
131
+ return {}
132
+
133
+ def save_instance_position(instance_name, data):
134
+ """Save position data for a single instance - no locking needed"""
135
+ instance_file = get_instance_file(instance_name)
136
+ instance_file.parent.mkdir(exist_ok=True)
137
+ atomic_write(instance_file, json.dumps(data, indent=2))
138
+
139
+ def load_all_positions():
140
+ """Load positions from all instance files"""
141
+ instances_dir = get_hcom_dir() / "instances"
142
+ if not instances_dir.exists():
143
+ return {}
144
+
145
+ positions = {}
146
+ for instance_file in instances_dir.glob("*.json"):
147
+ try:
148
+ instance_name = instance_file.stem
149
+ with open(instance_file, 'r') as f:
150
+ positions[instance_name] = json.load(f)
151
+ except (json.JSONDecodeError, FileNotFoundError):
152
+ continue
153
+ return positions
154
+
155
+ def clear_all_positions():
156
+ """Clear all instance position files"""
157
+ instances_dir = get_hcom_dir() / "instances"
158
+ if instances_dir.exists():
159
+ shutil.rmtree(instances_dir)
160
+ instances_dir.mkdir(exist_ok=True)
161
+
116
162
  # ==================== Configuration System ====================
117
163
 
118
164
  def get_cached_config():
@@ -141,8 +187,8 @@ def _load_config_from_file():
141
187
  else:
142
188
  config[key] = value
143
189
 
144
- except json.JSONDecodeError:
145
- print(format_warning("Invalid JSON in config file, using defaults"), file=sys.stderr)
190
+ except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
191
+ print(format_warning("Cannot read config file, using defaults"), file=sys.stderr)
146
192
  else:
147
193
  atomic_write(config_path, json.dumps(DEFAULT_CONFIG, indent=2))
148
194
 
@@ -191,14 +237,19 @@ def build_claude_env():
191
237
  """Build environment variables for Claude instances"""
192
238
  env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
193
239
 
240
+ # Get config file values
194
241
  config = get_cached_config()
242
+
243
+ # Pass env vars only when they differ from config file values
195
244
  for config_key, env_var in HOOK_SETTINGS.items():
196
- if config_key in config:
197
- config_value = config[config_key]
198
- default_value = DEFAULT_CONFIG.get(config_key)
199
- if config_value != default_value:
200
- env[env_var] = str(config_value)
245
+ actual_value = get_config_value(config_key) # Respects env var precedence
246
+ config_file_value = config.get(config_key)
247
+
248
+ # Only pass if different from config file (not default)
249
+ if actual_value != config_file_value and actual_value is not None:
250
+ env[env_var] = str(actual_value)
201
251
 
252
+ # Still support env_overrides from config file
202
253
  env.update(config.get('env_overrides', {}))
203
254
 
204
255
  # Set HCOM only for clean paths (spaces handled differently)
@@ -230,16 +281,10 @@ def require_args(min_count, usage_msg, extra_msg=""):
230
281
  print(extra_msg)
231
282
  sys.exit(1)
232
283
 
233
- def load_positions(pos_file):
234
- """Load positions from file with error handling"""
235
- positions = {}
236
- if pos_file.exists():
237
- try:
238
- with open(pos_file, 'r') as f:
239
- positions = json.load(f)
240
- except (json.JSONDecodeError, FileNotFoundError):
241
- pass
242
- return positions
284
+ def load_positions(pos_file=None):
285
+ """Load all positions - redirects to load_all_positions()"""
286
+ # pos_file parameter kept for compatibility but ignored
287
+ return load_all_positions()
243
288
 
244
289
  def send_message(from_instance, message):
245
290
  """Send a message to the log"""
@@ -280,12 +325,17 @@ def should_deliver_message(msg, instance_name, all_instance_names=None):
280
325
  if this_instance_matches:
281
326
  return True
282
327
 
283
- # If we have all_instance_names, check if ANY mention matches ANY instance
328
+ # Check if any mention is for the CLI sender (bigboss)
329
+ sender_name = get_config_value('sender_name', 'bigboss')
330
+ sender_mentioned = any(sender_name.lower().startswith(mention.lower()) for mention in mentions)
331
+
332
+ # If we have all_instance_names, check if ANY mention matches ANY instance or sender
284
333
  if all_instance_names:
285
334
  any_mention_matches = any(
286
335
  any(name.lower().startswith(mention.lower()) for name in all_instance_names)
287
336
  for mention in mentions
288
- )
337
+ ) or sender_mentioned
338
+
289
339
  if not any_mention_matches:
290
340
  return True # No matches anywhere = broadcast to all
291
341
 
@@ -297,14 +347,16 @@ def parse_open_args(args):
297
347
  """Parse arguments for open command
298
348
 
299
349
  Returns:
300
- tuple: (instances, prefix, claude_args)
350
+ tuple: (instances, prefix, claude_args, background)
301
351
  instances: list of agent names or 'generic'
302
352
  prefix: team name prefix or None
303
353
  claude_args: additional args to pass to claude
354
+ background: bool, True if --background or -p flag
304
355
  """
305
356
  instances = []
306
357
  prefix = None
307
358
  claude_args = []
359
+ background = False
308
360
 
309
361
  i = 0
310
362
  while i < len(args):
@@ -323,6 +375,9 @@ def parse_open_args(args):
323
375
  raise ValueError(format_error('--claude-args requires an argument'))
324
376
  claude_args = shlex.split(args[i + 1])
325
377
  i += 2
378
+ elif arg == '--background' or arg == '-p':
379
+ background = True
380
+ i += 1
326
381
  else:
327
382
  try:
328
383
  count = int(arg)
@@ -341,7 +396,7 @@ def parse_open_args(args):
341
396
  if not instances:
342
397
  instances = ['generic']
343
398
 
344
- return instances, prefix, claude_args
399
+ return instances, prefix, claude_args, background
345
400
 
346
401
  def extract_agent_config(content):
347
402
  """Extract configuration from agent YAML frontmatter"""
@@ -416,7 +471,8 @@ def get_display_name(transcript_path, prefix=None):
416
471
  uuid_char = conversation_uuid[0]
417
472
  base_name = f"{dir_chars}{syls[hash_val % len(syls)]}{uuid_char}"
418
473
  else:
419
- base_name = f"{dir_chars}claude"
474
+ pid_suffix = os.getppid() % 10000 # 4 digits max
475
+ base_name = f"{dir_chars}{pid_suffix}claude"
420
476
 
421
477
  if prefix:
422
478
  return f"{prefix}-{base_name}"
@@ -424,10 +480,13 @@ def get_display_name(transcript_path, prefix=None):
424
480
 
425
481
  def _remove_hcom_hooks_from_settings(settings):
426
482
  """Remove hcom hooks from settings dict"""
427
- if 'hooks' not in settings:
483
+ if not isinstance(settings, dict) or 'hooks' not in settings:
428
484
  return
429
485
 
430
- import re
486
+ if not isinstance(settings['hooks'], dict):
487
+ return
488
+
489
+ import copy
431
490
 
432
491
  # Patterns to match any hcom hook command
433
492
  # - $HCOM post/stop/notify
@@ -445,31 +504,47 @@ def _remove_hcom_hooks_from_settings(settings):
445
504
  r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command
446
505
  r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command
447
506
  r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote
448
- r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b', # Quoted path with hcom.py
507
+ r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b(?=\s|$)', # Quoted path with hcom.py (more precise)
449
508
  r'sh\s+-c.*hcom', # Shell wrapper with hcom
450
509
  ]
451
510
  compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
452
511
 
453
- for event in ['PostToolUse', 'Stop', 'Notification']:
512
+ for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
454
513
  if event not in settings['hooks']:
455
514
  continue
456
515
 
457
- settings['hooks'][event] = [
458
- matcher for matcher in settings['hooks'][event]
459
- if not any(
460
- any(
516
+ # Process each matcher
517
+ updated_matchers = []
518
+ for matcher in settings['hooks'][event]:
519
+ # Fail fast on malformed settings - Claude won't run with broken settings anyway
520
+ if not isinstance(matcher, dict):
521
+ raise ValueError(f"Malformed settings: matcher in {event} is not a dict: {type(matcher).__name__}")
522
+
523
+ # Work with a copy to avoid any potential reference issues
524
+ matcher_copy = copy.deepcopy(matcher)
525
+
526
+ # Filter out HCOM hooks from this matcher
527
+ non_hcom_hooks = [
528
+ hook for hook in matcher_copy.get('hooks', [])
529
+ if not any(
461
530
  pattern.search(hook.get('command', ''))
462
531
  for pattern in compiled_patterns
463
532
  )
464
- for hook in matcher.get('hooks', [])
465
- )
466
- ]
467
-
468
- if not settings['hooks'][event]:
533
+ ]
534
+
535
+ # Only keep the matcher if it has non-HCOM hooks remaining
536
+ if non_hcom_hooks:
537
+ matcher_copy['hooks'] = non_hcom_hooks
538
+ updated_matchers.append(matcher_copy)
539
+ elif not matcher.get('hooks'): # Preserve matchers that never had hooks
540
+ updated_matchers.append(matcher_copy)
541
+
542
+ # Update or remove the event
543
+ if updated_matchers:
544
+ settings['hooks'][event] = updated_matchers
545
+ else:
469
546
  del settings['hooks'][event]
470
547
 
471
- if not settings['hooks']:
472
- del settings['hooks']
473
548
 
474
549
  def build_env_string(env_vars, format_type="bash"):
475
550
  """Build environment variable string for different shells"""
@@ -571,7 +646,11 @@ def escape_for_platform(text, platform_type):
571
646
  .replace('\t', '\\t')) # Escape tabs
572
647
  elif platform_type == 'powershell':
573
648
  # PowerShell escaping - use backticks for special chars
574
- return text.replace('`', '``').replace('"', '`"').replace('$', '`$')
649
+ # Quote paths with spaces for PowerShell
650
+ escaped = text.replace('`', '``').replace('"', '`"').replace('$', '`$')
651
+ if ' ' in text and not (text.startswith('"') and text.endswith('"')):
652
+ return f'"{escaped}"'
653
+ return escaped
575
654
  else: # POSIX/bash
576
655
  return shlex.quote(text)
577
656
 
@@ -590,7 +669,7 @@ def safe_command_substitution(template, **substitutions):
590
669
  result = result.replace(placeholder, quoted_value)
591
670
  return result
592
671
 
593
- def launch_terminal(command, env, config=None, cwd=None):
672
+ def launch_terminal(command, env, config=None, cwd=None, background=False):
594
673
  """Launch terminal with command
595
674
 
596
675
  Args:
@@ -598,20 +677,56 @@ def launch_terminal(command, env, config=None, cwd=None):
598
677
  env: Environment variables to set
599
678
  config: Configuration dict
600
679
  cwd: Working directory
680
+ background: Launch as background process
601
681
  """
602
- import subprocess
603
-
604
682
  if config is None:
605
683
  config = get_cached_config()
606
684
 
607
685
  env_vars = os.environ.copy()
608
686
  env_vars.update(env)
609
687
 
610
- terminal_mode = get_config_value('terminal_mode', 'new_window')
611
-
612
688
  # Command should now always be a string from build_claude_command
613
689
  command_str = command
614
690
 
691
+ # Background mode implementation
692
+ if background:
693
+ # Create log file for background instance
694
+ logs_dir = get_hcom_dir() / 'logs'
695
+ logs_dir.mkdir(exist_ok=True)
696
+ log_file = logs_dir / env['HCOM_BACKGROUND']
697
+
698
+ # Launch detached process
699
+ try:
700
+ with open(log_file, 'w') as log_handle:
701
+ process = subprocess.Popen(
702
+ command_str,
703
+ shell=True,
704
+ env=env_vars,
705
+ cwd=cwd,
706
+ stdin=subprocess.DEVNULL,
707
+ stdout=log_handle,
708
+ stderr=subprocess.STDOUT,
709
+ start_new_session=True # detach from terminal session
710
+ )
711
+ except OSError as e:
712
+ print(f"Error: Failed to launch background instance: {e}", file=sys.stderr)
713
+ return None
714
+
715
+ # Check for immediate failures
716
+ time.sleep(0.2)
717
+ if process.poll() is not None:
718
+ # Process already exited
719
+ with open(log_file, 'r') as f:
720
+ error_output = f.read()[:1000]
721
+ print(f"Error: Background instance failed immediately", file=sys.stderr)
722
+ if error_output:
723
+ print(f" Output: {error_output}", file=sys.stderr)
724
+ return None
725
+
726
+ return str(log_file)
727
+
728
+ terminal_mode = get_config_value('terminal_mode', 'new_window')
729
+
615
730
  if terminal_mode == 'show_commands':
616
731
  env_str = build_env_string(env)
617
732
  print(f"{env_str} {command_str}")
@@ -675,7 +790,7 @@ def launch_terminal(command, env, config=None, cwd=None):
675
790
  subprocess.run(term_cmd + [full_cmd])
676
791
  return True
677
792
 
678
- raise Exception(format_error("No supported terminal emulator found", "Install gnome-terminal, konsole, xfce4-terminal, or xterm"))
793
+ raise Exception(format_error("No supported terminal emulator found", "Install gnome-terminal, konsole, or xterm"))
679
794
 
680
795
  elif system == 'Windows':
681
796
  # Windows Terminal with PowerShell
@@ -709,8 +824,8 @@ def setup_hooks():
709
824
  try:
710
825
  with open(settings_path, 'r') as f:
711
826
  settings = json.load(f)
712
- except json.JSONDecodeError:
713
- settings = {}
827
+ except (json.JSONDecodeError, PermissionError) as e:
828
+ raise Exception(format_error(f"Cannot read settings: {e}"))
714
829
 
715
830
  if 'hooks' not in settings:
716
831
  settings['hooks'] = {}
@@ -723,14 +838,22 @@ def setup_hooks():
723
838
 
724
839
  if 'hooks' not in settings:
725
840
  settings['hooks'] = {}
726
-
727
- hcom_send_permission = 'Bash(echo "HCOM_SEND:*")'
728
- if hcom_send_permission not in settings['permissions']['allow']:
729
- settings['permissions']['allow'].append(hcom_send_permission)
730
-
841
+
731
842
  # Get the hook command template
732
843
  hook_cmd_base, _ = get_hook_command()
733
844
 
845
+ # Add PreToolUse hook for auto-approving HCOM_SEND commands
846
+ if 'PreToolUse' not in settings['hooks']:
847
+ settings['hooks']['PreToolUse'] = []
848
+
849
+ settings['hooks']['PreToolUse'].append({
850
+ 'matcher': 'Bash',
851
+ 'hooks': [{
852
+ 'type': 'command',
853
+ 'command': f'{hook_cmd_base} pre'
854
+ }]
855
+ })
856
+
734
857
  # Add PostToolUse hook
735
858
  if 'PostToolUse' not in settings['hooks']:
736
859
  settings['hooks']['PostToolUse'] = []
@@ -771,17 +894,41 @@ def setup_hooks():
771
894
  })
772
895
 
773
896
  # Write settings atomically
774
- atomic_write(settings_path, json.dumps(settings, indent=2))
897
+ try:
898
+ atomic_write(settings_path, json.dumps(settings, indent=2))
899
+ except Exception as e:
900
+ raise Exception(format_error(f"Cannot write settings: {e}"))
901
+
902
+ # Quick verification
903
+ if not verify_hooks_installed(settings_path):
904
+ raise Exception(format_error("Hook installation failed"))
775
905
 
776
906
  return True
777
907
 
908
+ def verify_hooks_installed(settings_path):
909
+ """Verify that HCOM hooks were installed correctly"""
910
+ try:
911
+ with open(settings_path, 'r') as f:
912
+ settings = json.load(f)
913
+
914
+ # Check all 4 hook types exist with HCOM commands
915
+ hooks = settings.get('hooks', {})
916
+ for hook_type in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
917
+ if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
918
+ for h in hooks.get(hook_type, [])):
919
+ return False
920
+
921
+ return True
922
+ except Exception:
923
+ return False
924
+
778
925
  def is_interactive():
779
926
  """Check if running in interactive mode"""
780
927
  return sys.stdin.isatty() and sys.stdout.isatty()
781
928
 
782
929
  def get_archive_timestamp():
783
930
  """Get timestamp for archive files"""
784
- return datetime.now().strftime("%Y%m%d-%H%M%S")
931
+ return datetime.now().strftime("%Y-%m-%d_%H%M%S")
785
932
 
786
933
  def get_conversation_uuid(transcript_path):
787
934
  """Get conversation UUID from transcript
@@ -878,12 +1025,11 @@ def get_new_messages(instance_name):
878
1025
  """Get new messages for instance with @-mention filtering"""
879
1026
  ensure_hcom_dir()
880
1027
  log_file = get_hcom_dir() / "hcom.log"
881
- pos_file = get_hcom_dir() / "hcom.json"
882
1028
 
883
1029
  if not log_file.exists():
884
1030
  return []
885
1031
 
886
- positions = load_positions(pos_file)
1032
+ positions = load_all_positions()
887
1033
 
888
1034
  # Get last position for this instance
889
1035
  last_pos = 0
@@ -908,13 +1054,8 @@ def get_new_messages(instance_name):
908
1054
  f.seek(0, 2) # Seek to end
909
1055
  new_pos = f.tell()
910
1056
 
911
- # Update position file
912
- if instance_name not in positions:
913
- positions[instance_name] = {}
914
-
915
- positions[instance_name]['pos'] = new_pos
916
-
917
- atomic_write(pos_file, json.dumps(positions, indent=2))
1057
+ # Update position directly
1058
+ update_instance_position(instance_name, {'pos': new_pos})
918
1059
 
919
1060
  return messages
920
1061
 
@@ -964,7 +1105,19 @@ def get_transcript_status(transcript_path):
964
1105
  def get_instance_status(pos_data):
965
1106
  """Get current status of instance"""
966
1107
  now = int(time.time())
967
- wait_timeout = get_config_value('wait_timeout', 1800)
1108
+ wait_timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1109
+
1110
+ # Check if process is still alive. pid: null means killed
1111
+ # All real instances should have a PID (set by update_instance_with_pid)
1112
+ if 'pid' in pos_data:
1113
+ pid = pos_data['pid']
1114
+ if pid is None:
1115
+ # Explicitly null = was killed
1116
+ return "inactive", ""
1117
+ try:
1118
+ os.kill(int(pid), 0) # Check process existence
1119
+ except (ProcessLookupError, TypeError, ValueError):
1120
+ return "inactive", ""
968
1121
 
969
1122
  last_permission = pos_data.get("last_permission_request", 0)
970
1123
  last_stop = pos_data.get("last_stop", 0)
@@ -978,6 +1131,14 @@ def get_instance_status(pos_data):
978
1131
  status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
979
1132
  transcript_status = status
980
1133
 
1134
+ # Calculate last actual activity (excluding heartbeat)
1135
+ last_activity = max(last_permission, last_tool, transcript_timestamp)
1136
+
1137
+ # Check timeout based on actual activity
1138
+ if last_activity > 0 and (now - last_activity) > wait_timeout:
1139
+ return "inactive", ""
1140
+
1141
+ # Now determine current status including heartbeat
981
1142
  events = [
982
1143
  (last_permission, "blocked"),
983
1144
  (last_stop, "waiting"),
@@ -988,14 +1149,13 @@ def get_instance_status(pos_data):
988
1149
  recent_events = [(ts, status) for ts, status in events if ts > 0]
989
1150
  if not recent_events:
990
1151
  return "inactive", ""
991
-
1152
+
992
1153
  most_recent_time, most_recent_status = max(recent_events)
993
1154
  age = now - most_recent_time
994
1155
 
995
- if age > wait_timeout:
996
- return "inactive", ""
997
-
998
- return most_recent_status, f"({format_age(age)})"
1156
+ # Add background indicator
1157
+ status_suffix = " (bg)" if pos_data.get('background') else ""
1158
+ return most_recent_status, f"({format_age(age)}){status_suffix}"
999
1159
 
1000
1160
  def get_status_block(status_type):
1001
1161
  """Get colored status block for a status type"""
@@ -1053,12 +1213,7 @@ def show_recent_activity_alt_screen(limit=None):
1053
1213
 
1054
1214
  def show_instances_status():
1055
1215
  """Show status of all instances"""
1056
- pos_file = get_hcom_dir() / "hcom.json"
1057
- if not pos_file.exists():
1058
- print(f" {DIM}No Claude instances connected{RESET}")
1059
- return
1060
-
1061
- positions = load_positions(pos_file)
1216
+ positions = load_all_positions()
1062
1217
  if not positions:
1063
1218
  print(f" {DIM}No Claude instances connected{RESET}")
1064
1219
  return
@@ -1072,12 +1227,7 @@ def show_instances_status():
1072
1227
 
1073
1228
  def show_instances_by_directory():
1074
1229
  """Show instances organized by their working directories"""
1075
- pos_file = get_hcom_dir() / "hcom.json"
1076
- if not pos_file.exists():
1077
- print(f" {DIM}No Claude instances connected{RESET}")
1078
- return
1079
-
1080
- positions = load_positions(pos_file)
1230
+ positions = load_all_positions()
1081
1231
  if positions:
1082
1232
  directories = {}
1083
1233
  for instance_name, pos_data in positions.items():
@@ -1138,11 +1288,7 @@ def alt_screen_detailed_status_and_input():
1138
1288
 
1139
1289
  def get_status_summary():
1140
1290
  """Get a one-line summary of all instance statuses"""
1141
- pos_file = get_hcom_dir() / "hcom.json"
1142
- if not pos_file.exists():
1143
- return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1144
-
1145
- positions = load_positions(pos_file)
1291
+ positions = load_all_positions()
1146
1292
  if not positions:
1147
1293
  return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1148
1294
 
@@ -1181,12 +1327,9 @@ def log_line_with_status(message, status):
1181
1327
 
1182
1328
  def initialize_instance_in_position_file(instance_name, conversation_uuid=None):
1183
1329
  """Initialize an instance in the position file with all required fields"""
1184
- ensure_hcom_dir()
1185
- pos_file = get_hcom_dir() / "hcom.json"
1186
- positions = load_positions(pos_file)
1187
-
1188
- if instance_name not in positions:
1189
- positions[instance_name] = {
1330
+ instance_file = get_instance_file(instance_name)
1331
+ if not instance_file.exists():
1332
+ save_instance_position(instance_name, {
1190
1333
  "pos": 0,
1191
1334
  "directory": str(Path.cwd()),
1192
1335
  "conversation_uuid": conversation_uuid or "unknown",
@@ -1198,58 +1341,36 @@ def initialize_instance_in_position_file(instance_name, conversation_uuid=None):
1198
1341
  "session_id": "",
1199
1342
  "help_shown": False,
1200
1343
  "notification_message": ""
1201
- }
1202
- atomic_write(pos_file, json.dumps(positions, indent=2))
1344
+ })
1203
1345
 
1204
1346
  def migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path):
1205
1347
  """Migrate instance name from fallback to UUID-based if needed"""
1206
1348
  if instance_name.endswith("claude") and conversation_uuid:
1207
1349
  new_instance = get_display_name(transcript_path)
1208
1350
  if new_instance != instance_name and not new_instance.endswith("claude"):
1209
- # Always return the new name if we can generate it
1210
1351
  # Migration of data only happens if old name exists
1211
- pos_file = get_hcom_dir() / "hcom.json"
1212
- positions = load_positions(pos_file)
1213
- if instance_name in positions:
1214
- # Copy over the old instance data to new name
1215
- positions[new_instance] = positions.pop(instance_name)
1216
- # Update the conversation UUID in the migrated data
1217
- positions[new_instance]["conversation_uuid"] = conversation_uuid
1218
- atomic_write(pos_file, json.dumps(positions, indent=2))
1352
+ old_file = get_instance_file(instance_name)
1353
+ if old_file.exists():
1354
+ data = load_instance_position(instance_name)
1355
+ data["conversation_uuid"] = conversation_uuid
1356
+ save_instance_position(new_instance, data)
1357
+ old_file.unlink()
1219
1358
  return new_instance
1220
1359
  return instance_name
1221
1360
 
1222
1361
  def update_instance_position(instance_name, update_fields):
1223
- """Update instance position in position file"""
1224
- ensure_hcom_dir()
1225
- pos_file = get_hcom_dir() / "hcom.json"
1226
-
1227
- # Get file modification time before reading to detect races
1228
- mtime_before = pos_file.stat().st_mtime_ns if pos_file.exists() else 0
1229
- positions = load_positions(pos_file)
1230
-
1231
- # Get or create instance data
1232
- if instance_name not in positions:
1233
- positions[instance_name] = {}
1234
-
1235
- # Update only provided fields
1236
- for key, value in update_fields.items():
1237
- positions[instance_name][key] = value
1238
-
1239
- # Check if file was modified while we were working
1240
- mtime_after = pos_file.stat().st_mtime_ns if pos_file.exists() else 0
1241
- if mtime_after != mtime_before:
1242
- # Someone else modified it, retry once
1243
- return update_instance_position(instance_name, update_fields)
1244
-
1245
- # Write back atomically
1246
- atomic_write(pos_file, json.dumps(positions, indent=2))
1362
+ """Update instance position - direct file write, no locking needed"""
1363
+ data = load_instance_position(instance_name)
1364
+ data.update(update_fields)
1365
+ save_instance_position(instance_name, data)
1247
1366
 
1248
- def check_and_show_first_use_help(instance_name):
1249
- """Check and show first-use help if needed"""
1367
+ def get_first_use_text(instance_name):
1368
+ """Get first-use help text if not shown yet
1250
1369
 
1251
- pos_file = get_hcom_dir() / "hcom.json"
1252
- positions = load_positions(pos_file)
1370
+ Returns:
1371
+ str: Welcome text if not shown before, empty string otherwise
1372
+ """
1373
+ positions = load_all_positions()
1253
1374
 
1254
1375
  instance_data = positions.get(instance_name, {})
1255
1376
  if not instance_data.get('help_shown', False):
@@ -1260,10 +1381,20 @@ def check_and_show_first_use_help(instance_name):
1260
1381
  first_use_text = get_config_value('first_use_text', '')
1261
1382
  instance_hints = get_config_value('instance_hints', '')
1262
1383
 
1263
- help_text = f"Welcome! hcom chat active. Your alias: {instance_name}. " \
1264
- f"Send messages: echo \"HCOM_SEND:your message\". " \
1265
- f"{first_use_text} {instance_hints}".strip()
1384
+ help_text = f"[Welcome! hcom chat active. Your alias: {instance_name}. " \
1385
+ f"Send messages: echo 'HCOM_SEND:your message']"
1386
+ if first_use_text:
1387
+ help_text += f" [{first_use_text}]"
1388
+ if instance_hints:
1389
+ help_text += f" [{instance_hints}]"
1266
1390
 
1391
+ return help_text
1392
+ return ""
1393
+
1394
+ def check_and_show_first_use_help(instance_name):
1395
+ """Check and show first-use help if needed (for Stop hook)"""
1396
+ help_text = get_first_use_text(instance_name)
1397
+ if help_text:
1267
1398
  output = {"decision": HOOK_DECISION_BLOCK, "reason": help_text}
1268
1399
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1269
1400
  sys.exit(EXIT_BLOCK)
@@ -1290,34 +1421,109 @@ def show_main_screen_header():
1290
1421
 
1291
1422
  return all_messages
1292
1423
 
1424
+ def show_cli_hints(to_stderr=True):
1425
+ """Show CLI hints if configured"""
1426
+ cli_hints = get_config_value('cli_hints', '')
1427
+ if cli_hints:
1428
+ if to_stderr:
1429
+ print(f"\n{cli_hints}", file=sys.stderr)
1430
+ else:
1431
+ print(f"\n{cli_hints}")
1432
+
1293
1433
  def cmd_help():
1294
1434
  """Show help text"""
1435
+ # Basic help for interactive users
1295
1436
  print("""hcom - Claude Hook Comms
1296
1437
 
1297
1438
  Usage:
1298
1439
  hcom open [n] Launch n Claude instances
1299
1440
  hcom open <agent> Launch named agent from .claude/agents/
1300
1441
  hcom open --prefix <team> n Launch n instances with team prefix
1442
+ hcom open --background Launch instances as background processes (-p also works)
1301
1443
  hcom watch View conversation dashboard
1302
1444
  hcom clear Clear and archive conversation
1303
1445
  hcom cleanup Remove hooks from current directory
1304
1446
  hcom cleanup --all Remove hooks from all tracked directories
1447
+ hcom kill [instance alias] Kill specific instance
1448
+ hcom kill --all Kill all running instances
1305
1449
  hcom help Show this help
1306
1450
 
1307
1451
  Automation:
1308
- hcom send 'msg' Send message
1452
+ hcom send 'msg' Send message to all
1309
1453
  hcom send '@prefix msg' Send to specific instances
1310
- hcom watch --logs Show logs
1311
- hcom watch --status Show status
1454
+ hcom watch --logs Show conversation log
1455
+ hcom watch --status Show status of instances
1456
+ hcom watch --wait [seconds] Wait for new messages (default 60s)
1312
1457
 
1313
1458
  Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
1459
+
1460
+ # Additional help for AI assistants when running in non-interactive mode
1461
+ if not sys.stdin.isatty():
1462
+ print("""
1463
+
1464
+ === ADDITIONAL INFO ===
1465
+
1466
+ CONCEPT: HCOM creates multi-agent collaboration by launching multiple Claude Code
1467
+ instances in separate terminals that share a group chat.
1468
+
1469
+ KEY UNDERSTANDING:
1470
+ • Single conversation - All instances share ~/.hcom/hcom.log
1471
+ • CLI usage - Use 'hcom send' for messaging. Internal instances know to use 'echo HCOM_SEND:'
1472
+ • hcom open is directory-specific - always cd to project directory first
1473
+ • hcom watch --wait outputs existing logs, then waits for the next message, prints it, and exits.
1474
+ Times out after [seconds]
1475
+ • Named agents are custom system prompts created by users/claude code.
1476
+ "reviewer" named agent loads .claude/agents/reviewer.md (if it was ever created)
1477
+
1478
+ LAUNCH PATTERNS:
1479
+ hcom open 2 reviewer # 2 generic + 1 reviewer agent
1480
+ hcom open reviewer reviewer # 2 separate reviewer instances
1481
+ hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
1482
+ hcom open --claude-args "--model sonnet" # Pass 'claude' CLI flags
1483
+ hcom open --background (or -p) then hcom kill # Detached background process
1484
+ hcom watch --status (get sessionid) then hcom open --claude-args "--resume <sessionid>"
1485
+ HCOM_INITIAL_PROMPT="do x task" hcom open # initial prompt to instance
1486
+
1487
+ @MENTION TARGETING:
1488
+ hcom send "message" # Broadcasts to everyone
1489
+ hcom send "@api fix this" # Targets all api-* instances (api-hova7, api-kolec)
1490
+ hcom send "@hova7 status?" # Targets specific instance
1491
+ (Unmatched @mentions broadcast to everyone)
1492
+
1493
+ STATUS INDICATORS:
1494
+ • ◉ thinking, ▷ responding, ▶ executing - instance is working
1495
+ • ◉ waiting - instance is waiting for new messages
1496
+ • ■ blocked - instance is blocked by permission request (needs user approval)
1497
+ • ○ inactive - instance is timed out, disconnected, etc
1498
+
1499
+ CONFIG:
1500
+ Config file (persistent s): ~/.hcom/config.json
1501
+
1502
+ Key settings (full list in config.json):
1503
+ terminal_mode: "new_window" (default) | "same_terminal" | "show_commands"
1504
+ initial_prompt: "Say hi in chat", first_use_text: "Essential messages only..."
1505
+ instance_hints: "text", cli_hints: "text" # Extra info for instances/CLI
1506
+
1507
+ Temporary environment overrides for any setting (all caps & append HCOM_):
1508
+ HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages recieved by instance
1509
+ export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
1510
+
1511
+ EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
1512
+ with 'hcom watch --status'. Instances respond automatically in shared chat.""")
1513
+
1514
+ show_cli_hints(to_stderr=False)
1515
+
1314
1516
  return 0
1315
1517
 
1316
1518
  def cmd_open(*args):
1317
1519
  """Launch Claude instances with chat enabled"""
1318
1520
  try:
1319
1521
  # Parse arguments
1320
- instances, prefix, claude_args = parse_open_args(list(args))
1522
+ instances, prefix, claude_args, background = parse_open_args(list(args))
1523
+
1524
+ # Add -p flag and stream-json output for background mode if not already present
1525
+ if background and '-p' not in claude_args and '--print' not in claude_args:
1526
+ claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
1321
1527
 
1322
1528
  terminal_mode = get_config_value('terminal_mode', 'new_window')
1323
1529
 
@@ -1337,19 +1543,19 @@ def cmd_open(*args):
1337
1543
 
1338
1544
  ensure_hcom_dir()
1339
1545
  log_file = get_hcom_dir() / 'hcom.log'
1340
- pos_file = get_hcom_dir() / 'hcom.json'
1546
+ instances_dir = get_hcom_dir() / 'instances'
1341
1547
 
1342
1548
  if not log_file.exists():
1343
1549
  log_file.touch()
1344
- if not pos_file.exists():
1345
- atomic_write(pos_file, json.dumps({}, indent=2))
1550
+ if not instances_dir.exists():
1551
+ instances_dir.mkdir(exist_ok=True)
1346
1552
 
1347
1553
  # Build environment variables for Claude instances
1348
1554
  base_env = build_claude_env()
1349
1555
 
1350
1556
  # Add prefix-specific hints if provided
1351
1557
  if prefix:
1352
- hint = f"To respond to {prefix} group: echo \"HCOM_SEND:@{prefix} message\""
1558
+ hint = f"To respond to {prefix} group: echo 'HCOM_SEND:@{prefix} message'"
1353
1559
  base_env['HCOM_INSTANCE_HINTS'] = hint
1354
1560
 
1355
1561
  first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
@@ -1361,6 +1567,14 @@ def cmd_open(*args):
1361
1567
  temp_files_to_cleanup = []
1362
1568
 
1363
1569
  for instance_type in instances:
1570
+ instance_env = base_env.copy()
1571
+
1572
+ # Mark background instances via environment with log filename
1573
+ if background:
1574
+ # Generate unique log filename
1575
+ log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
1576
+ instance_env['HCOM_BACKGROUND'] = log_filename
1577
+
1364
1578
  # Build claude command
1365
1579
  if instance_type == 'generic':
1366
1580
  # Generic instance - no agent content
@@ -1373,6 +1587,9 @@ def cmd_open(*args):
1373
1587
  # Agent instance
1374
1588
  try:
1375
1589
  agent_content, agent_config = resolve_agent(instance_type)
1590
+ # Prepend agent instance awareness to system prompt
1591
+ agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
1592
+ agent_content = agent_prefix + agent_content
1376
1593
  # Use agent's model and tools if specified and not overridden in claude_args
1377
1594
  agent_model = agent_config.get('model')
1378
1595
  agent_tools = agent_config.get('tools')
@@ -1390,8 +1607,14 @@ def cmd_open(*args):
1390
1607
  continue
1391
1608
 
1392
1609
  try:
1393
- launch_terminal(claude_cmd, base_env, cwd=os.getcwd())
1394
- launched += 1
1610
+ if background:
1611
+ log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
1612
+ if log_file:
1613
+ print(f"Background instance launched, log: {log_file}")
1614
+ launched += 1
1615
+ else:
1616
+ launch_terminal(claude_cmd, instance_env, cwd=os.getcwd())
1617
+ launched += 1
1395
1618
  except Exception as e:
1396
1619
  print(f"Error: Failed to launch terminal: {e}", file=sys.stderr)
1397
1620
 
@@ -1424,6 +1647,12 @@ def cmd_open(*args):
1424
1647
 
1425
1648
  print("\n" + "\n".join(f" • {tip}" for tip in tips))
1426
1649
 
1650
+ # Show cli_hints if configured (non-interactive mode)
1651
+ if not is_interactive():
1652
+ cli_hints = get_config_value('cli_hints', '')
1653
+ if cli_hints:
1654
+ print(f"\n{cli_hints}")
1655
+
1427
1656
  return 0
1428
1657
 
1429
1658
  except ValueError as e:
@@ -1436,9 +1665,9 @@ def cmd_open(*args):
1436
1665
  def cmd_watch(*args):
1437
1666
  """View conversation dashboard"""
1438
1667
  log_file = get_hcom_dir() / 'hcom.log'
1439
- pos_file = get_hcom_dir() / 'hcom.json'
1668
+ instances_dir = get_hcom_dir() / 'instances'
1440
1669
 
1441
- if not log_file.exists() and not pos_file.exists():
1670
+ if not log_file.exists() and not instances_dir.exists():
1442
1671
  print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
1443
1672
  return 1
1444
1673
 
@@ -1447,72 +1676,116 @@ def cmd_watch(*args):
1447
1676
  show_status = False
1448
1677
  wait_timeout = None
1449
1678
 
1450
- for arg in args:
1679
+ i = 0
1680
+ while i < len(args):
1681
+ arg = args[i]
1451
1682
  if arg == '--logs':
1452
1683
  show_logs = True
1453
1684
  elif arg == '--status':
1454
1685
  show_status = True
1455
- elif arg.startswith('--wait='):
1456
- try:
1457
- wait_timeout = int(arg.split('=')[1])
1458
- except ValueError:
1459
- print(format_error("Invalid timeout value"), file=sys.stderr)
1460
- return 1
1461
1686
  elif arg == '--wait':
1462
- # Default wait timeout if no value provided
1463
- wait_timeout = 60
1687
+ # Check if next arg is a number
1688
+ if i + 1 < len(args) and args[i + 1].isdigit():
1689
+ wait_timeout = int(args[i + 1])
1690
+ i += 1 # Skip the number
1691
+ else:
1692
+ wait_timeout = 60 # Default
1693
+ i += 1
1694
+
1695
+ # If wait is specified, enable logs to show the messages
1696
+ if wait_timeout is not None:
1697
+ show_logs = True
1464
1698
 
1465
1699
  # Non-interactive mode (no TTY or flags specified)
1466
1700
  if not is_interactive() or show_logs or show_status:
1467
1701
  if show_logs:
1702
+ # Atomic position capture BEFORE parsing (prevents race condition)
1468
1703
  if log_file.exists():
1704
+ last_pos = log_file.stat().st_size # Capture position first
1469
1705
  messages = parse_log_messages(log_file)
1470
- for msg in messages:
1471
- print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1472
1706
  else:
1473
- print("No messages yet")
1474
-
1707
+ last_pos = 0
1708
+ messages = []
1709
+
1710
+ # If --wait, show only recent messages to prevent context bloat
1475
1711
  if wait_timeout is not None:
1476
- start_time = time.time()
1477
- last_pos = log_file.stat().st_size if log_file.exists() else 0
1712
+ cutoff = datetime.now() - timedelta(seconds=5)
1713
+ recent_messages = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
1714
+
1715
+ # Status to stderr, data to stdout
1716
+ if recent_messages:
1717
+ print(f'---Showing last 5 seconds of messages---', file=sys.stderr)
1718
+ for msg in recent_messages:
1719
+ print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1720
+ print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
1721
+ else:
1722
+ print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
1478
1723
 
1724
+ # Wait loop
1725
+ start_time = time.time()
1479
1726
  while time.time() - start_time < wait_timeout:
1480
1727
  if log_file.exists() and log_file.stat().st_size > last_pos:
1728
+ # Capture new position BEFORE parsing (atomic)
1729
+ new_pos = log_file.stat().st_size
1481
1730
  new_messages = parse_log_messages(log_file, last_pos)
1482
- for msg in new_messages:
1483
- print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1484
- last_pos = log_file.stat().st_size
1485
- break
1486
- time.sleep(1)
1731
+ if new_messages:
1732
+ for msg in new_messages:
1733
+ print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1734
+ last_pos = new_pos # Update only after successful processing
1735
+ return 0 # Success - got new messages
1736
+ last_pos = new_pos # Update even if no messages (file grew but no complete messages yet)
1737
+ time.sleep(0.1)
1738
+
1739
+ # Timeout message to stderr
1740
+ print(f'[TIMED OUT] No new messages received after {wait_timeout} seconds.', file=sys.stderr)
1741
+ return 1 # Timeout - no new messages
1742
+
1743
+ # Regular --logs (no --wait): print all messages to stdout
1744
+ else:
1745
+ if messages:
1746
+ for msg in messages:
1747
+ print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1748
+ else:
1749
+ print("No messages yet", file=sys.stderr)
1750
+
1751
+ show_cli_hints()
1487
1752
 
1488
1753
  elif show_status:
1754
+ # Status information to stdout for parsing
1489
1755
  print("HCOM STATUS")
1490
1756
  print("INSTANCES:")
1491
1757
  show_instances_status()
1492
1758
  print("\nRECENT ACTIVITY:")
1493
1759
  show_recent_activity_alt_screen()
1494
1760
  print(f"\nLOG FILE: {log_file}")
1761
+
1762
+ show_cli_hints()
1495
1763
  else:
1496
- # No TTY - show automation usage
1497
- print("Automation usage:")
1498
- print(" hcom send 'message' Send message to group")
1499
- print(" hcom watch --logs Show message history")
1500
- print(" hcom watch --status Show instance status")
1764
+ # No TTY - show automation usage to stderr
1765
+ print("Automation usage:", file=sys.stderr)
1766
+ print(" hcom send 'message' Send message to group", file=sys.stderr)
1767
+ print(" hcom watch --logs Show message history", file=sys.stderr)
1768
+ print(" hcom watch --status Show instance status", file=sys.stderr)
1769
+ print(" hcom watch --wait Wait for new messages", file=sys.stderr)
1770
+
1771
+ show_cli_hints()
1501
1772
 
1502
1773
  return 0
1503
1774
 
1504
1775
  # Interactive dashboard mode
1505
- last_pos = 0
1506
1776
  status_suffix = f"{DIM} [⏎]...{RESET}"
1507
1777
 
1778
+ # Atomic position capture BEFORE showing messages (prevents race condition)
1779
+ if log_file.exists():
1780
+ last_pos = log_file.stat().st_size
1781
+ else:
1782
+ last_pos = 0
1783
+
1508
1784
  all_messages = show_main_screen_header()
1509
1785
 
1510
1786
  show_recent_messages(all_messages, limit=5)
1511
1787
  print(f"\n{DIM}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
1512
1788
 
1513
- if log_file.exists():
1514
- last_pos = log_file.stat().st_size
1515
-
1516
1789
  # Print newline to ensure status starts on its own line
1517
1790
  print()
1518
1791
 
@@ -1589,49 +1862,64 @@ def cmd_clear():
1589
1862
  """Clear and archive conversation"""
1590
1863
  ensure_hcom_dir()
1591
1864
  log_file = get_hcom_dir() / 'hcom.log'
1592
- pos_file = get_hcom_dir() / 'hcom.json'
1865
+ instances_dir = get_hcom_dir() / 'instances'
1866
+ archive_folder = get_hcom_dir() / 'archive'
1593
1867
 
1594
1868
  # Check if hcom files exist
1595
- if not log_file.exists() and not pos_file.exists():
1869
+ if not log_file.exists() and not instances_dir.exists():
1596
1870
  print("No hcom conversation to clear")
1597
1871
  return 0
1598
1872
 
1599
1873
  # Generate archive timestamp
1600
1874
  timestamp = get_archive_timestamp()
1601
1875
 
1876
+ # Ensure archive folder exists
1877
+ archive_folder.mkdir(exist_ok=True)
1878
+
1602
1879
  # Archive existing files if they have content
1603
1880
  archived = False
1604
1881
 
1605
1882
  try:
1606
- # Archive log file if it exists and has content
1607
- if log_file.exists() and log_file.stat().st_size > 0:
1608
- archive_log = get_hcom_dir() / f'hcom-{timestamp}.log'
1609
- log_file.rename(archive_log)
1610
- archived = True
1611
- elif log_file.exists():
1612
- log_file.unlink()
1613
-
1614
- # Archive position file if it exists and has content
1615
- if pos_file.exists():
1616
- try:
1617
- with open(pos_file, 'r') as f:
1618
- data = json.load(f)
1619
- if data: # Non-empty position file
1620
- archive_pos = get_hcom_dir() / f'hcom-{timestamp}.json'
1621
- pos_file.rename(archive_pos)
1622
- archived = True
1623
- else:
1624
- pos_file.unlink()
1625
- except (json.JSONDecodeError, FileNotFoundError):
1626
- if pos_file.exists():
1627
- pos_file.unlink()
1883
+ # Create session archive folder if we have content to archive
1884
+ has_log = log_file.exists() and log_file.stat().st_size > 0
1885
+ has_instances = instances_dir.exists() and any(instances_dir.glob('*.json'))
1886
+
1887
+ if has_log or has_instances:
1888
+ # Create session archive folder with timestamp
1889
+ session_archive = archive_folder / f'session-{timestamp}'
1890
+ session_archive.mkdir(exist_ok=True)
1891
+
1892
+ # Archive log file
1893
+ if has_log:
1894
+ archive_log = session_archive / 'hcom.log'
1895
+ log_file.rename(archive_log)
1896
+ archived = True
1897
+ elif log_file.exists():
1898
+ log_file.unlink()
1899
+
1900
+ # Archive instances
1901
+ if has_instances:
1902
+ archive_instances = session_archive / 'instances'
1903
+ archive_instances.mkdir(exist_ok=True)
1904
+
1905
+ # Move json files only (background logs stay in logs folder)
1906
+ for f in instances_dir.glob('*.json'):
1907
+ f.rename(archive_instances / f.name)
1908
+
1909
+ archived = True
1910
+ else:
1911
+ # Clean up empty files/dirs
1912
+ if log_file.exists():
1913
+ log_file.unlink()
1914
+ if instances_dir.exists():
1915
+ shutil.rmtree(instances_dir)
1628
1916
 
1629
1917
  log_file.touch()
1630
- atomic_write(pos_file, json.dumps({}, indent=2))
1918
+ clear_all_positions()
1631
1919
 
1632
1920
  if archived:
1633
- print(f"Archived conversations to hcom-{timestamp}")
1634
- print("Started fresh hcom conversation")
1921
+ print(f"Archived to archive/session-{timestamp}/")
1922
+ print("Started fresh hcom conversation log")
1635
1923
  return 0
1636
1924
 
1637
1925
  except Exception as e:
@@ -1658,31 +1946,16 @@ def cleanup_directory_hooks(directory):
1658
1946
  hooks_found = False
1659
1947
 
1660
1948
  original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
1661
- for event in ['PostToolUse', 'Stop', 'Notification'])
1949
+ for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
1662
1950
 
1663
1951
  _remove_hcom_hooks_from_settings(settings)
1664
1952
 
1665
1953
  # Check if any were removed
1666
1954
  new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
1667
- for event in ['PostToolUse', 'Stop', 'Notification'])
1955
+ for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
1668
1956
  if new_hook_count < original_hook_count:
1669
1957
  hooks_found = True
1670
-
1671
- if 'permissions' in settings and 'allow' in settings['permissions']:
1672
- original_perms = settings['permissions']['allow'][:]
1673
- settings['permissions']['allow'] = [
1674
- perm for perm in settings['permissions']['allow']
1675
- if 'HCOM_SEND' not in perm
1676
- ]
1677
-
1678
- if len(settings['permissions']['allow']) < len(original_perms):
1679
- hooks_found = True
1680
-
1681
- if not settings['permissions']['allow']:
1682
- del settings['permissions']['allow']
1683
- if not settings['permissions']:
1684
- del settings['permissions']
1685
-
1958
+
1686
1959
  if not hooks_found:
1687
1960
  return 0, "No hcom hooks found"
1688
1961
 
@@ -1702,39 +1975,77 @@ def cleanup_directory_hooks(directory):
1702
1975
  return 1, format_error(f"Cannot modify settings.local.json: {e}")
1703
1976
 
1704
1977
 
1978
+ def cmd_kill(*args):
1979
+ """Kill instances by name or all with --all"""
1980
+ import signal
1981
+
1982
+ instance_name = args[0] if args and args[0] != '--all' else None
1983
+ positions = load_all_positions() if not instance_name else {instance_name: load_instance_position(instance_name)}
1984
+
1985
+ # Filter to instances with PIDs (any instance that's running)
1986
+ targets = [(name, data) for name, data in positions.items() if data.get('pid')]
1987
+
1988
+ if not targets:
1989
+ print(f"No running process found for {instance_name}" if instance_name else "No running instances found")
1990
+ return 1 if instance_name else 0
1991
+
1992
+ killed_count = 0
1993
+ for target_name, target_data in targets:
1994
+ # Get status and type info before killing
1995
+ status, age = get_instance_status(target_data)
1996
+ instance_type = "background" if target_data.get('background') else "foreground"
1997
+
1998
+ pid = int(target_data['pid'])
1999
+ try:
2000
+ # Send SIGTERM for graceful shutdown
2001
+ os.kill(pid, signal.SIGTERM)
2002
+
2003
+ # Wait up to 2 seconds for process to terminate
2004
+ for _ in range(20):
2005
+ time.sleep(0.1)
2006
+ try:
2007
+ os.kill(pid, 0) # Check if process still exists
2008
+ except ProcessLookupError:
2009
+ # Process terminated successfully
2010
+ break
2011
+ else:
2012
+ # Process didn't die from SIGTERM, force kill
2013
+ try:
2014
+ os.kill(pid, signal.SIGKILL)
2015
+ time.sleep(0.1) # Brief wait for SIGKILL to take effect
2016
+ except ProcessLookupError:
2017
+ pass # Already dead
2018
+
2019
+ print(f"Killed {target_name} ({instance_type}, {status}{age}, PID {pid})")
2020
+ killed_count += 1
2021
+ except (ProcessLookupError, TypeError, ValueError) as e:
2022
+ print(f"Process {pid} already dead or invalid: {e}")
2023
+
2024
+ # Mark instance as killed
2025
+ update_instance_position(target_name, {'pid': None})
2026
+
2027
+ if not instance_name:
2028
+ print(f"Killed {killed_count} instance(s)")
2029
+
2030
+ return 0
2031
+
1705
2032
  def cmd_cleanup(*args):
1706
2033
  """Remove hcom hooks from current directory or all directories"""
1707
2034
  if args and args[0] == '--all':
1708
2035
  directories = set()
1709
2036
 
1710
- # Get all directories from current position file
1711
- pos_file = get_hcom_dir() / 'hcom.json'
1712
- if pos_file.exists():
1713
- try:
1714
- positions = load_positions(pos_file)
2037
+ # Get all directories from current instances
2038
+ try:
2039
+ positions = load_all_positions()
2040
+ if positions:
1715
2041
  for instance_data in positions.values():
1716
2042
  if isinstance(instance_data, dict) and 'directory' in instance_data:
1717
2043
  directories.add(instance_data['directory'])
1718
- except Exception as e:
1719
- print(format_warning(f"Could not read current position file: {e}"))
1720
-
1721
- hcom_dir = get_hcom_dir()
1722
- try:
1723
- # Look for archived position files (hcom-TIMESTAMP.json)
1724
- for archive_file in hcom_dir.glob('hcom-*.json'):
1725
- try:
1726
- with open(archive_file, 'r') as f:
1727
- archived_positions = json.load(f)
1728
- for instance_data in archived_positions.values():
1729
- if isinstance(instance_data, dict) and 'directory' in instance_data:
1730
- directories.add(instance_data['directory'])
1731
- except Exception as e:
1732
- print(format_warning(f"Could not read archive {archive_file.name}: {e}"))
1733
2044
  except Exception as e:
1734
- print(format_warning(f"Could not scan for archived files: {e}"))
2045
+ print(format_warning(f"Could not read current instances: {e}"))
1735
2046
 
1736
2047
  if not directories:
1737
- print("No directories found in hcom tracking (current or archived)")
2048
+ print("No directories found in current hcom tracking")
1738
2049
  return 0
1739
2050
 
1740
2051
  print(f"Found {len(directories)} unique directories to check")
@@ -1786,9 +2097,9 @@ def cmd_send(message):
1786
2097
  """Send message to hcom"""
1787
2098
  # Check if hcom files exist
1788
2099
  log_file = get_hcom_dir() / 'hcom.log'
1789
- pos_file = get_hcom_dir() / 'hcom.json'
2100
+ instances_dir = get_hcom_dir() / 'instances'
1790
2101
 
1791
- if not log_file.exists() and not pos_file.exists():
2102
+ if not log_file.exists() and not instances_dir.exists():
1792
2103
  print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
1793
2104
  return 1
1794
2105
 
@@ -1800,14 +2111,14 @@ def cmd_send(message):
1800
2111
 
1801
2112
  # Check for unmatched mentions (minimal warning)
1802
2113
  mentions = MENTION_PATTERN.findall(message)
1803
- if mentions and pos_file.exists():
2114
+ if mentions:
1804
2115
  try:
1805
- positions = load_positions(pos_file)
2116
+ positions = load_all_positions()
1806
2117
  all_instances = list(positions.keys())
1807
2118
  unmatched = [m for m in mentions
1808
2119
  if not any(name.lower().startswith(m.lower()) for name in all_instances)]
1809
2120
  if unmatched:
1810
- print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all")
2121
+ print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
1811
2122
  except Exception:
1812
2123
  pass # Don't fail on warning
1813
2124
 
@@ -1815,7 +2126,12 @@ def cmd_send(message):
1815
2126
  sender_name = get_config_value('sender_name', 'bigboss')
1816
2127
 
1817
2128
  if send_message(sender_name, message):
1818
- print("Message sent")
2129
+ print("Message sent", file=sys.stderr)
2130
+
2131
+ # Show cli_hints if configured (non-interactive mode)
2132
+ if not is_interactive():
2133
+ show_cli_hints()
2134
+
1819
2135
  return 0
1820
2136
  else:
1821
2137
  print(format_error("Failed to send message"), file=sys.stderr)
@@ -1823,18 +2139,25 @@ def cmd_send(message):
1823
2139
 
1824
2140
  # ==================== Hook Functions ====================
1825
2141
 
2142
+ def update_instance_with_pid(updates, existing_data):
2143
+ """Add PID to updates if not already set in existing data"""
2144
+ if not existing_data.get('pid'):
2145
+ updates['pid'] = os.getppid() # Parent PID is the Claude process
2146
+ return updates
2147
+
1826
2148
  def format_hook_messages(messages, instance_name):
1827
2149
  """Format messages for hook feedback"""
1828
2150
  if len(messages) == 1:
1829
2151
  msg = messages[0]
1830
- reason = f"{msg['from']} → {instance_name}: {msg['message']}"
2152
+ reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
1831
2153
  else:
1832
- parts = [f"{msg['from']}: {msg['message']}" for msg in messages]
1833
- reason = f"{len(messages)} messages {instance_name}: " + " | ".join(parts)
2154
+ parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
2155
+ reason = f"[{len(messages)} new messages] | " + " | ".join(parts)
1834
2156
 
2157
+ # Only append instance_hints to messages (first_use_text is handled separately)
1835
2158
  instance_hints = get_config_value('instance_hints', '')
1836
2159
  if instance_hints:
1837
- reason = f"{reason} {instance_hints}"
2160
+ reason = f"{reason} | [{instance_hints}]"
1838
2161
 
1839
2162
  return reason
1840
2163
 
@@ -1856,14 +2179,32 @@ def handle_hook_post():
1856
2179
 
1857
2180
  initialize_instance_in_position_file(instance_name, conversation_uuid)
1858
2181
 
1859
- update_instance_position(instance_name, {
2182
+ # Get existing position data to check if already set
2183
+ existing_data = load_instance_position(instance_name)
2184
+
2185
+ updates = {
1860
2186
  'last_tool': int(time.time()),
1861
2187
  'last_tool_name': hook_data.get('tool_name', 'unknown'),
1862
2188
  'session_id': hook_data.get('session_id', ''),
1863
2189
  'transcript_path': transcript_path,
1864
2190
  'conversation_uuid': conversation_uuid or 'unknown',
1865
- 'directory': str(Path.cwd())
1866
- })
2191
+ 'directory': str(Path.cwd()),
2192
+ }
2193
+
2194
+ # Update PID if not already set
2195
+ update_instance_with_pid(updates, existing_data)
2196
+
2197
+ # Check if this is a background instance and save log file
2198
+ bg_env = os.environ.get('HCOM_BACKGROUND')
2199
+ if bg_env:
2200
+ updates['background'] = True
2201
+ updates['background_log_file'] = str(get_hcom_dir() / 'logs' / bg_env)
2202
+
2203
+ update_instance_position(instance_name, updates)
2204
+
2205
+ # Check for first-use help (prepend to first activity)
2206
+ welcome_text = get_first_use_text(instance_name)
2207
+ welcome_prefix = f"{welcome_text} | " if welcome_text else ""
1867
2208
 
1868
2209
  # Check for HCOM_SEND in Bash commands
1869
2210
  sent_reason = None
@@ -1901,25 +2242,42 @@ def handle_hook_post():
1901
2242
  sys.exit(EXIT_BLOCK)
1902
2243
 
1903
2244
  send_message(instance_name, message)
1904
- sent_reason = "✓ Sent"
2245
+ sent_reason = "[✓ Sent]"
1905
2246
 
1906
2247
  messages = get_new_messages(instance_name)
1907
2248
 
2249
+ # Limit messages to max_messages_per_delivery
2250
+ if messages:
2251
+ max_messages = get_config_value('max_messages_per_delivery', 50)
2252
+ messages = messages[:max_messages]
2253
+
1908
2254
  if messages and sent_reason:
1909
2255
  # Both sent and received
1910
2256
  reason = f"{sent_reason} | {format_hook_messages(messages, instance_name)}"
2257
+ if welcome_prefix:
2258
+ reason = f"{welcome_prefix}{reason}"
1911
2259
  output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
1912
2260
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1913
2261
  sys.exit(EXIT_BLOCK)
1914
2262
  elif messages:
1915
2263
  # Just received
1916
2264
  reason = format_hook_messages(messages, instance_name)
2265
+ if welcome_prefix:
2266
+ reason = f"{welcome_prefix}{reason}"
1917
2267
  output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
1918
2268
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1919
2269
  sys.exit(EXIT_BLOCK)
1920
2270
  elif sent_reason:
1921
2271
  # Just sent
1922
- output = {"reason": sent_reason}
2272
+ reason = sent_reason
2273
+ if welcome_prefix:
2274
+ reason = f"{welcome_prefix}{reason}"
2275
+ output = {"reason": reason}
2276
+ print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2277
+ sys.exit(EXIT_BLOCK)
2278
+ elif welcome_prefix:
2279
+ # No activity but need to show welcome
2280
+ output = {"decision": HOOK_DECISION_BLOCK, "reason": welcome_prefix.rstrip(' | ')}
1923
2281
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1924
2282
  sys.exit(EXIT_BLOCK)
1925
2283
 
@@ -1941,17 +2299,38 @@ def handle_hook_stop():
1941
2299
  instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
1942
2300
  conversation_uuid = get_conversation_uuid(transcript_path)
1943
2301
 
2302
+ # Migrate instance name if needed (from fallback to UUID-based)
2303
+ instance_name = migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path)
2304
+
1944
2305
  # Initialize instance if needed
1945
2306
  initialize_instance_in_position_file(instance_name, conversation_uuid)
1946
2307
 
1947
- # Update instance as waiting
1948
- update_instance_position(instance_name, {
1949
- 'last_stop': int(time.time()),
2308
+ # Get the timeout this hook will use
2309
+ timeout = get_config_value('wait_timeout', 1800)
2310
+
2311
+ # Get existing position data to check if PID is already set
2312
+ existing_data = load_instance_position(instance_name)
2313
+
2314
+ # Update instance as waiting and store the timeout
2315
+ updates = {
2316
+ 'last_stop': time.time(),
2317
+ 'wait_timeout': timeout, # Store this instance's timeout
1950
2318
  'session_id': hook_data.get('session_id', ''),
1951
2319
  'transcript_path': transcript_path,
1952
2320
  'conversation_uuid': conversation_uuid or 'unknown',
1953
- 'directory': str(Path.cwd())
1954
- })
2321
+ 'directory': str(Path.cwd()),
2322
+ }
2323
+
2324
+ # Update PID if not already set
2325
+ update_instance_with_pid(updates, existing_data)
2326
+
2327
+ # Check if this is a background instance and save log file
2328
+ bg_env = os.environ.get('HCOM_BACKGROUND')
2329
+ if bg_env:
2330
+ updates['background'] = True
2331
+ updates['background_log_file'] = str(get_hcom_dir() / 'logs' / bg_env)
2332
+
2333
+ update_instance_position(instance_name, updates)
1955
2334
 
1956
2335
  parent_pid = os.getppid()
1957
2336
 
@@ -1959,7 +2338,6 @@ def handle_hook_stop():
1959
2338
  check_and_show_first_use_help(instance_name)
1960
2339
 
1961
2340
  # Simple polling loop with parent check
1962
- timeout = get_config_value('wait_timeout', 1800)
1963
2341
  start_time = time.time()
1964
2342
 
1965
2343
  while time.time() - start_time < timeout:
@@ -1982,10 +2360,10 @@ def handle_hook_stop():
1982
2360
 
1983
2361
  # Update heartbeat
1984
2362
  update_instance_position(instance_name, {
1985
- 'last_stop': int(time.time())
2363
+ 'last_stop': time.time()
1986
2364
  })
1987
2365
 
1988
- time.sleep(1)
2366
+ time.sleep(0.1)
1989
2367
 
1990
2368
  except Exception:
1991
2369
  pass
@@ -2008,15 +2386,29 @@ def handle_hook_notification():
2008
2386
  # Initialize instance if needed
2009
2387
  initialize_instance_in_position_file(instance_name, conversation_uuid)
2010
2388
 
2389
+ # Get existing position data to check if PID is already set
2390
+ existing_data = load_instance_position(instance_name)
2391
+
2011
2392
  # Update permission request timestamp
2012
- update_instance_position(instance_name, {
2393
+ updates = {
2013
2394
  'last_permission_request': int(time.time()),
2014
2395
  'notification_message': hook_data.get('message', ''),
2015
2396
  'session_id': hook_data.get('session_id', ''),
2016
2397
  'transcript_path': transcript_path,
2017
2398
  'conversation_uuid': conversation_uuid or 'unknown',
2018
- 'directory': str(Path.cwd())
2019
- })
2399
+ 'directory': str(Path.cwd()),
2400
+ }
2401
+
2402
+ # Update PID if not already set
2403
+ update_instance_with_pid(updates, existing_data)
2404
+
2405
+ # Check if this is a background instance and save log file
2406
+ bg_env = os.environ.get('HCOM_BACKGROUND')
2407
+ if bg_env:
2408
+ updates['background'] = True
2409
+ updates['background_log_file'] = str(get_hcom_dir() / 'logs' / bg_env)
2410
+
2411
+ update_instance_position(instance_name, updates)
2020
2412
 
2021
2413
  check_and_show_first_use_help(instance_name)
2022
2414
 
@@ -2025,6 +2417,41 @@ def handle_hook_notification():
2025
2417
 
2026
2418
  sys.exit(EXIT_SUCCESS)
2027
2419
 
2420
+ def handle_hook_pretooluse():
2421
+ """Handle PreToolUse hook - auto-approve HCOM_SEND commands"""
2422
+ # Check if active
2423
+ if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
2424
+ sys.exit(EXIT_SUCCESS)
2425
+
2426
+ try:
2427
+ # Read hook input
2428
+ hook_data = json.load(sys.stdin)
2429
+ tool_name = hook_data.get('tool_name', '')
2430
+ tool_input = hook_data.get('tool_input', {})
2431
+
2432
+ # Only process Bash commands
2433
+ if tool_name == 'Bash':
2434
+ command = tool_input.get('command', '')
2435
+
2436
+ # Check if it's an HCOM_SEND command
2437
+ if 'HCOM_SEND:' in command:
2438
+ # Auto-approve HCOM_SEND commands
2439
+ output = {
2440
+ "hookSpecificOutput": {
2441
+ "hookEventName": "PreToolUse",
2442
+ "permissionDecision": "allow",
2443
+ "permissionDecisionReason": "HCOM_SEND command auto-approved"
2444
+ }
2445
+ }
2446
+ print(json.dumps(output, ensure_ascii=False))
2447
+ sys.exit(EXIT_SUCCESS)
2448
+
2449
+ except Exception:
2450
+ pass
2451
+
2452
+ # Let normal permission flow continue for non-HCOM_SEND commands
2453
+ sys.exit(EXIT_SUCCESS)
2454
+
2028
2455
  # ==================== Main Entry Point ====================
2029
2456
 
2030
2457
  def main(argv=None):
@@ -2053,6 +2480,8 @@ def main(argv=None):
2053
2480
  print(format_error("Message required"), file=sys.stderr)
2054
2481
  return 1
2055
2482
  return cmd_send(argv[2])
2483
+ elif cmd == 'kill':
2484
+ return cmd_kill(*argv[2:])
2056
2485
 
2057
2486
  # Hook commands
2058
2487
  elif cmd == 'post':
@@ -2064,6 +2493,9 @@ def main(argv=None):
2064
2493
  elif cmd == 'notify':
2065
2494
  handle_hook_notification()
2066
2495
  return 0
2496
+ elif cmd == 'pre':
2497
+ handle_hook_pretooluse()
2498
+ return 0
2067
2499
 
2068
2500
 
2069
2501
  # Unknown command