hcom 0.1.8__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,6 +1421,15 @@ 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"""
1295
1435
  # Basic help for interactive users
@@ -1299,18 +1439,21 @@ Usage:
1299
1439
  hcom open [n] Launch n Claude instances
1300
1440
  hcom open <agent> Launch named agent from .claude/agents/
1301
1441
  hcom open --prefix <team> n Launch n instances with team prefix
1442
+ hcom open --background Launch instances as background processes (-p also works)
1302
1443
  hcom watch View conversation dashboard
1303
1444
  hcom clear Clear and archive conversation
1304
1445
  hcom cleanup Remove hooks from current directory
1305
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
1306
1449
  hcom help Show this help
1307
1450
 
1308
1451
  Automation:
1309
1452
  hcom send 'msg' Send message to all
1310
1453
  hcom send '@prefix msg' Send to specific instances
1311
- hcom watch --logs Show logs
1312
- hcom watch --status Show status
1313
- hcom watch --wait [timeout] Wait and notify for new messages (seconds)
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)
1314
1457
 
1315
1458
  Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
1316
1459
 
@@ -1321,19 +1464,25 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
1321
1464
  === ADDITIONAL INFO ===
1322
1465
 
1323
1466
  CONCEPT: HCOM creates multi-agent collaboration by launching multiple Claude Code
1324
- instances in separate terminals that share a single conversation.
1467
+ instances in separate terminals that share a group chat.
1325
1468
 
1326
1469
  KEY UNDERSTANDING:
1327
1470
  • Single conversation - All instances share ~/.hcom/hcom.log
1328
- Agents are system prompts - "reviewer" loads .claude/agents/reviewer.md
1329
- CLI usage - Use 'hcom send' for messaging. Internal instances use 'echo HCOM_SEND:'
1330
- • hcom open is directory-specific - always cd to project directory first
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)
1331
1477
 
1332
1478
  LAUNCH PATTERNS:
1333
1479
  hcom open 2 reviewer # 2 generic + 1 reviewer agent
1334
1480
  hcom open reviewer reviewer # 2 separate reviewer instances
1335
1481
  hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
1336
- hcom open test --claude-args "-p 'write tests'" # Pass 'claude' CLI flags
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
1337
1486
 
1338
1487
  @MENTION TARGETING:
1339
1488
  hcom send "message" # Broadcasts to everyone
@@ -1343,21 +1492,26 @@ LAUNCH PATTERNS:
1343
1492
 
1344
1493
  STATUS INDICATORS:
1345
1494
  • ◉ thinking, ▷ responding, ▶ executing - instance is working
1346
- • ◉ waiting - instance is waiting for new messages (hcom send)
1495
+ • ◉ waiting - instance is waiting for new messages
1347
1496
  • ■ blocked - instance is blocked by permission request (needs user approval)
1348
- • ○ inactive - instance is inactive (timed out, disconnected, etc)
1497
+ • ○ inactive - instance is timed out, disconnected, etc
1349
1498
 
1350
1499
  CONFIG:
1351
- Environment overrides (temporary): HCOM_INSTANCE_HINTS="useful info" hcom send "hi"
1352
- Config file (persistent): ~/.hcom/config.json
1500
+ Config file (persistent s): ~/.hcom/config.json
1353
1501
 
1354
- Key settings (all in config.json):
1355
- terminal_mode: "new_window" | "same_terminal" | "show_commands"
1356
- initial_prompt: "Say hi in chat", first_use_text: "Essential, concise messages only..."
1357
- instance_hints: "", cli_hints: "" # Extra info for instances/CLI
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
1358
1506
 
1359
- EXPECT: Instance names are auto-generated (5-char format based on uuid: "hova7"). Check actual names
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
1360
1512
  with 'hcom watch --status'. Instances respond automatically in shared chat.""")
1513
+
1514
+ show_cli_hints(to_stderr=False)
1361
1515
 
1362
1516
  return 0
1363
1517
 
@@ -1365,7 +1519,11 @@ def cmd_open(*args):
1365
1519
  """Launch Claude instances with chat enabled"""
1366
1520
  try:
1367
1521
  # Parse arguments
1368
- 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 [])
1369
1527
 
1370
1528
  terminal_mode = get_config_value('terminal_mode', 'new_window')
1371
1529
 
@@ -1385,19 +1543,19 @@ def cmd_open(*args):
1385
1543
 
1386
1544
  ensure_hcom_dir()
1387
1545
  log_file = get_hcom_dir() / 'hcom.log'
1388
- pos_file = get_hcom_dir() / 'hcom.json'
1546
+ instances_dir = get_hcom_dir() / 'instances'
1389
1547
 
1390
1548
  if not log_file.exists():
1391
1549
  log_file.touch()
1392
- if not pos_file.exists():
1393
- atomic_write(pos_file, json.dumps({}, indent=2))
1550
+ if not instances_dir.exists():
1551
+ instances_dir.mkdir(exist_ok=True)
1394
1552
 
1395
1553
  # Build environment variables for Claude instances
1396
1554
  base_env = build_claude_env()
1397
1555
 
1398
1556
  # Add prefix-specific hints if provided
1399
1557
  if prefix:
1400
- 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'"
1401
1559
  base_env['HCOM_INSTANCE_HINTS'] = hint
1402
1560
 
1403
1561
  first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
@@ -1409,6 +1567,14 @@ def cmd_open(*args):
1409
1567
  temp_files_to_cleanup = []
1410
1568
 
1411
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
+
1412
1578
  # Build claude command
1413
1579
  if instance_type == 'generic':
1414
1580
  # Generic instance - no agent content
@@ -1421,6 +1587,9 @@ def cmd_open(*args):
1421
1587
  # Agent instance
1422
1588
  try:
1423
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
1424
1593
  # Use agent's model and tools if specified and not overridden in claude_args
1425
1594
  agent_model = agent_config.get('model')
1426
1595
  agent_tools = agent_config.get('tools')
@@ -1438,8 +1607,14 @@ def cmd_open(*args):
1438
1607
  continue
1439
1608
 
1440
1609
  try:
1441
- launch_terminal(claude_cmd, base_env, cwd=os.getcwd())
1442
- 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
1443
1618
  except Exception as e:
1444
1619
  print(f"Error: Failed to launch terminal: {e}", file=sys.stderr)
1445
1620
 
@@ -1472,6 +1647,12 @@ def cmd_open(*args):
1472
1647
 
1473
1648
  print("\n" + "\n".join(f" • {tip}" for tip in tips))
1474
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
+
1475
1656
  return 0
1476
1657
 
1477
1658
  except ValueError as e:
@@ -1484,9 +1665,9 @@ def cmd_open(*args):
1484
1665
  def cmd_watch(*args):
1485
1666
  """View conversation dashboard"""
1486
1667
  log_file = get_hcom_dir() / 'hcom.log'
1487
- pos_file = get_hcom_dir() / 'hcom.json'
1668
+ instances_dir = get_hcom_dir() / 'instances'
1488
1669
 
1489
- if not log_file.exists() and not pos_file.exists():
1670
+ if not log_file.exists() and not instances_dir.exists():
1490
1671
  print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
1491
1672
  return 1
1492
1673
 
@@ -1495,72 +1676,116 @@ def cmd_watch(*args):
1495
1676
  show_status = False
1496
1677
  wait_timeout = None
1497
1678
 
1498
- for arg in args:
1679
+ i = 0
1680
+ while i < len(args):
1681
+ arg = args[i]
1499
1682
  if arg == '--logs':
1500
1683
  show_logs = True
1501
1684
  elif arg == '--status':
1502
1685
  show_status = True
1503
- elif arg.startswith('--wait='):
1504
- try:
1505
- wait_timeout = int(arg.split('=')[1])
1506
- except ValueError:
1507
- print(format_error("Invalid timeout value"), file=sys.stderr)
1508
- return 1
1509
1686
  elif arg == '--wait':
1510
- # Default wait timeout if no value provided
1511
- 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
1512
1698
 
1513
1699
  # Non-interactive mode (no TTY or flags specified)
1514
1700
  if not is_interactive() or show_logs or show_status:
1515
1701
  if show_logs:
1702
+ # Atomic position capture BEFORE parsing (prevents race condition)
1516
1703
  if log_file.exists():
1704
+ last_pos = log_file.stat().st_size # Capture position first
1517
1705
  messages = parse_log_messages(log_file)
1518
- for msg in messages:
1519
- print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1520
1706
  else:
1521
- print("No messages yet")
1522
-
1707
+ last_pos = 0
1708
+ messages = []
1709
+
1710
+ # If --wait, show only recent messages to prevent context bloat
1523
1711
  if wait_timeout is not None:
1524
- start_time = time.time()
1525
- 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)
1526
1723
 
1724
+ # Wait loop
1725
+ start_time = time.time()
1527
1726
  while time.time() - start_time < wait_timeout:
1528
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
1529
1730
  new_messages = parse_log_messages(log_file, last_pos)
1530
- for msg in new_messages:
1531
- print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1532
- last_pos = log_file.stat().st_size
1533
- break
1534
- 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()
1535
1752
 
1536
1753
  elif show_status:
1754
+ # Status information to stdout for parsing
1537
1755
  print("HCOM STATUS")
1538
1756
  print("INSTANCES:")
1539
1757
  show_instances_status()
1540
1758
  print("\nRECENT ACTIVITY:")
1541
1759
  show_recent_activity_alt_screen()
1542
1760
  print(f"\nLOG FILE: {log_file}")
1761
+
1762
+ show_cli_hints()
1543
1763
  else:
1544
- # No TTY - show automation usage
1545
- print("Automation usage:")
1546
- print(" hcom send 'message' Send message to group")
1547
- print(" hcom watch --logs Show message history")
1548
- 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()
1549
1772
 
1550
1773
  return 0
1551
1774
 
1552
1775
  # Interactive dashboard mode
1553
- last_pos = 0
1554
1776
  status_suffix = f"{DIM} [⏎]...{RESET}"
1555
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
+
1556
1784
  all_messages = show_main_screen_header()
1557
1785
 
1558
1786
  show_recent_messages(all_messages, limit=5)
1559
1787
  print(f"\n{DIM}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
1560
1788
 
1561
- if log_file.exists():
1562
- last_pos = log_file.stat().st_size
1563
-
1564
1789
  # Print newline to ensure status starts on its own line
1565
1790
  print()
1566
1791
 
@@ -1637,49 +1862,64 @@ def cmd_clear():
1637
1862
  """Clear and archive conversation"""
1638
1863
  ensure_hcom_dir()
1639
1864
  log_file = get_hcom_dir() / 'hcom.log'
1640
- pos_file = get_hcom_dir() / 'hcom.json'
1865
+ instances_dir = get_hcom_dir() / 'instances'
1866
+ archive_folder = get_hcom_dir() / 'archive'
1641
1867
 
1642
1868
  # Check if hcom files exist
1643
- if not log_file.exists() and not pos_file.exists():
1869
+ if not log_file.exists() and not instances_dir.exists():
1644
1870
  print("No hcom conversation to clear")
1645
1871
  return 0
1646
1872
 
1647
1873
  # Generate archive timestamp
1648
1874
  timestamp = get_archive_timestamp()
1649
1875
 
1876
+ # Ensure archive folder exists
1877
+ archive_folder.mkdir(exist_ok=True)
1878
+
1650
1879
  # Archive existing files if they have content
1651
1880
  archived = False
1652
1881
 
1653
1882
  try:
1654
- # Archive log file if it exists and has content
1655
- if log_file.exists() and log_file.stat().st_size > 0:
1656
- archive_log = get_hcom_dir() / f'hcom-{timestamp}.log'
1657
- log_file.rename(archive_log)
1658
- archived = True
1659
- elif log_file.exists():
1660
- log_file.unlink()
1661
-
1662
- # Archive position file if it exists and has content
1663
- if pos_file.exists():
1664
- try:
1665
- with open(pos_file, 'r') as f:
1666
- data = json.load(f)
1667
- if data: # Non-empty position file
1668
- archive_pos = get_hcom_dir() / f'hcom-{timestamp}.json'
1669
- pos_file.rename(archive_pos)
1670
- archived = True
1671
- else:
1672
- pos_file.unlink()
1673
- except (json.JSONDecodeError, FileNotFoundError):
1674
- if pos_file.exists():
1675
- 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)
1676
1916
 
1677
1917
  log_file.touch()
1678
- atomic_write(pos_file, json.dumps({}, indent=2))
1918
+ clear_all_positions()
1679
1919
 
1680
1920
  if archived:
1681
- print(f"Archived conversations to hcom-{timestamp}")
1682
- print("Started fresh hcom conversation")
1921
+ print(f"Archived to archive/session-{timestamp}/")
1922
+ print("Started fresh hcom conversation log")
1683
1923
  return 0
1684
1924
 
1685
1925
  except Exception as e:
@@ -1706,31 +1946,16 @@ def cleanup_directory_hooks(directory):
1706
1946
  hooks_found = False
1707
1947
 
1708
1948
  original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
1709
- for event in ['PostToolUse', 'Stop', 'Notification'])
1949
+ for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
1710
1950
 
1711
1951
  _remove_hcom_hooks_from_settings(settings)
1712
1952
 
1713
1953
  # Check if any were removed
1714
1954
  new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
1715
- for event in ['PostToolUse', 'Stop', 'Notification'])
1955
+ for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
1716
1956
  if new_hook_count < original_hook_count:
1717
1957
  hooks_found = True
1718
-
1719
- if 'permissions' in settings and 'allow' in settings['permissions']:
1720
- original_perms = settings['permissions']['allow'][:]
1721
- settings['permissions']['allow'] = [
1722
- perm for perm in settings['permissions']['allow']
1723
- if 'HCOM_SEND' not in perm
1724
- ]
1725
-
1726
- if len(settings['permissions']['allow']) < len(original_perms):
1727
- hooks_found = True
1728
-
1729
- if not settings['permissions']['allow']:
1730
- del settings['permissions']['allow']
1731
- if not settings['permissions']:
1732
- del settings['permissions']
1733
-
1958
+
1734
1959
  if not hooks_found:
1735
1960
  return 0, "No hcom hooks found"
1736
1961
 
@@ -1750,39 +1975,77 @@ def cleanup_directory_hooks(directory):
1750
1975
  return 1, format_error(f"Cannot modify settings.local.json: {e}")
1751
1976
 
1752
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
+
1753
2032
  def cmd_cleanup(*args):
1754
2033
  """Remove hcom hooks from current directory or all directories"""
1755
2034
  if args and args[0] == '--all':
1756
2035
  directories = set()
1757
2036
 
1758
- # Get all directories from current position file
1759
- pos_file = get_hcom_dir() / 'hcom.json'
1760
- if pos_file.exists():
1761
- try:
1762
- positions = load_positions(pos_file)
2037
+ # Get all directories from current instances
2038
+ try:
2039
+ positions = load_all_positions()
2040
+ if positions:
1763
2041
  for instance_data in positions.values():
1764
2042
  if isinstance(instance_data, dict) and 'directory' in instance_data:
1765
2043
  directories.add(instance_data['directory'])
1766
- except Exception as e:
1767
- print(format_warning(f"Could not read current position file: {e}"))
1768
-
1769
- hcom_dir = get_hcom_dir()
1770
- try:
1771
- # Look for archived position files (hcom-TIMESTAMP.json)
1772
- for archive_file in hcom_dir.glob('hcom-*.json'):
1773
- try:
1774
- with open(archive_file, 'r') as f:
1775
- archived_positions = json.load(f)
1776
- for instance_data in archived_positions.values():
1777
- if isinstance(instance_data, dict) and 'directory' in instance_data:
1778
- directories.add(instance_data['directory'])
1779
- except Exception as e:
1780
- print(format_warning(f"Could not read archive {archive_file.name}: {e}"))
1781
2044
  except Exception as e:
1782
- print(format_warning(f"Could not scan for archived files: {e}"))
2045
+ print(format_warning(f"Could not read current instances: {e}"))
1783
2046
 
1784
2047
  if not directories:
1785
- print("No directories found in hcom tracking (current or archived)")
2048
+ print("No directories found in current hcom tracking")
1786
2049
  return 0
1787
2050
 
1788
2051
  print(f"Found {len(directories)} unique directories to check")
@@ -1834,9 +2097,9 @@ def cmd_send(message):
1834
2097
  """Send message to hcom"""
1835
2098
  # Check if hcom files exist
1836
2099
  log_file = get_hcom_dir() / 'hcom.log'
1837
- pos_file = get_hcom_dir() / 'hcom.json'
2100
+ instances_dir = get_hcom_dir() / 'instances'
1838
2101
 
1839
- if not log_file.exists() and not pos_file.exists():
2102
+ if not log_file.exists() and not instances_dir.exists():
1840
2103
  print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
1841
2104
  return 1
1842
2105
 
@@ -1848,14 +2111,14 @@ def cmd_send(message):
1848
2111
 
1849
2112
  # Check for unmatched mentions (minimal warning)
1850
2113
  mentions = MENTION_PATTERN.findall(message)
1851
- if mentions and pos_file.exists():
2114
+ if mentions:
1852
2115
  try:
1853
- positions = load_positions(pos_file)
2116
+ positions = load_all_positions()
1854
2117
  all_instances = list(positions.keys())
1855
2118
  unmatched = [m for m in mentions
1856
2119
  if not any(name.lower().startswith(m.lower()) for name in all_instances)]
1857
2120
  if unmatched:
1858
- 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)
1859
2122
  except Exception:
1860
2123
  pass # Don't fail on warning
1861
2124
 
@@ -1863,7 +2126,12 @@ def cmd_send(message):
1863
2126
  sender_name = get_config_value('sender_name', 'bigboss')
1864
2127
 
1865
2128
  if send_message(sender_name, message):
1866
- 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
+
1867
2135
  return 0
1868
2136
  else:
1869
2137
  print(format_error("Failed to send message"), file=sys.stderr)
@@ -1871,18 +2139,25 @@ def cmd_send(message):
1871
2139
 
1872
2140
  # ==================== Hook Functions ====================
1873
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
+
1874
2148
  def format_hook_messages(messages, instance_name):
1875
2149
  """Format messages for hook feedback"""
1876
2150
  if len(messages) == 1:
1877
2151
  msg = messages[0]
1878
- reason = f"{msg['from']} → {instance_name}: {msg['message']}"
2152
+ reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
1879
2153
  else:
1880
- parts = [f"{msg['from']}: {msg['message']}" for msg in messages]
1881
- 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)
1882
2156
 
2157
+ # Only append instance_hints to messages (first_use_text is handled separately)
1883
2158
  instance_hints = get_config_value('instance_hints', '')
1884
2159
  if instance_hints:
1885
- reason = f"{reason} {instance_hints}"
2160
+ reason = f"{reason} | [{instance_hints}]"
1886
2161
 
1887
2162
  return reason
1888
2163
 
@@ -1904,14 +2179,32 @@ def handle_hook_post():
1904
2179
 
1905
2180
  initialize_instance_in_position_file(instance_name, conversation_uuid)
1906
2181
 
1907
- 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 = {
1908
2186
  'last_tool': int(time.time()),
1909
2187
  'last_tool_name': hook_data.get('tool_name', 'unknown'),
1910
2188
  'session_id': hook_data.get('session_id', ''),
1911
2189
  'transcript_path': transcript_path,
1912
2190
  'conversation_uuid': conversation_uuid or 'unknown',
1913
- 'directory': str(Path.cwd())
1914
- })
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 ""
1915
2208
 
1916
2209
  # Check for HCOM_SEND in Bash commands
1917
2210
  sent_reason = None
@@ -1949,25 +2242,42 @@ def handle_hook_post():
1949
2242
  sys.exit(EXIT_BLOCK)
1950
2243
 
1951
2244
  send_message(instance_name, message)
1952
- sent_reason = "✓ Sent"
2245
+ sent_reason = "[✓ Sent]"
1953
2246
 
1954
2247
  messages = get_new_messages(instance_name)
1955
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
+
1956
2254
  if messages and sent_reason:
1957
2255
  # Both sent and received
1958
2256
  reason = f"{sent_reason} | {format_hook_messages(messages, instance_name)}"
2257
+ if welcome_prefix:
2258
+ reason = f"{welcome_prefix}{reason}"
1959
2259
  output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
1960
2260
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1961
2261
  sys.exit(EXIT_BLOCK)
1962
2262
  elif messages:
1963
2263
  # Just received
1964
2264
  reason = format_hook_messages(messages, instance_name)
2265
+ if welcome_prefix:
2266
+ reason = f"{welcome_prefix}{reason}"
1965
2267
  output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
1966
2268
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1967
2269
  sys.exit(EXIT_BLOCK)
1968
2270
  elif sent_reason:
1969
2271
  # Just sent
1970
- 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(' | ')}
1971
2281
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1972
2282
  sys.exit(EXIT_BLOCK)
1973
2283
 
@@ -1989,17 +2299,38 @@ def handle_hook_stop():
1989
2299
  instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
1990
2300
  conversation_uuid = get_conversation_uuid(transcript_path)
1991
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
+
1992
2305
  # Initialize instance if needed
1993
2306
  initialize_instance_in_position_file(instance_name, conversation_uuid)
1994
2307
 
1995
- # Update instance as waiting
1996
- update_instance_position(instance_name, {
1997
- '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
1998
2318
  'session_id': hook_data.get('session_id', ''),
1999
2319
  'transcript_path': transcript_path,
2000
2320
  'conversation_uuid': conversation_uuid or 'unknown',
2001
- 'directory': str(Path.cwd())
2002
- })
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)
2003
2334
 
2004
2335
  parent_pid = os.getppid()
2005
2336
 
@@ -2007,7 +2338,6 @@ def handle_hook_stop():
2007
2338
  check_and_show_first_use_help(instance_name)
2008
2339
 
2009
2340
  # Simple polling loop with parent check
2010
- timeout = get_config_value('wait_timeout', 1800)
2011
2341
  start_time = time.time()
2012
2342
 
2013
2343
  while time.time() - start_time < timeout:
@@ -2030,10 +2360,10 @@ def handle_hook_stop():
2030
2360
 
2031
2361
  # Update heartbeat
2032
2362
  update_instance_position(instance_name, {
2033
- 'last_stop': int(time.time())
2363
+ 'last_stop': time.time()
2034
2364
  })
2035
2365
 
2036
- time.sleep(1)
2366
+ time.sleep(0.1)
2037
2367
 
2038
2368
  except Exception:
2039
2369
  pass
@@ -2056,15 +2386,29 @@ def handle_hook_notification():
2056
2386
  # Initialize instance if needed
2057
2387
  initialize_instance_in_position_file(instance_name, conversation_uuid)
2058
2388
 
2389
+ # Get existing position data to check if PID is already set
2390
+ existing_data = load_instance_position(instance_name)
2391
+
2059
2392
  # Update permission request timestamp
2060
- update_instance_position(instance_name, {
2393
+ updates = {
2061
2394
  'last_permission_request': int(time.time()),
2062
2395
  'notification_message': hook_data.get('message', ''),
2063
2396
  'session_id': hook_data.get('session_id', ''),
2064
2397
  'transcript_path': transcript_path,
2065
2398
  'conversation_uuid': conversation_uuid or 'unknown',
2066
- 'directory': str(Path.cwd())
2067
- })
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)
2068
2412
 
2069
2413
  check_and_show_first_use_help(instance_name)
2070
2414
 
@@ -2073,6 +2417,41 @@ def handle_hook_notification():
2073
2417
 
2074
2418
  sys.exit(EXIT_SUCCESS)
2075
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
+
2076
2455
  # ==================== Main Entry Point ====================
2077
2456
 
2078
2457
  def main(argv=None):
@@ -2101,6 +2480,8 @@ def main(argv=None):
2101
2480
  print(format_error("Message required"), file=sys.stderr)
2102
2481
  return 1
2103
2482
  return cmd_send(argv[2])
2483
+ elif cmd == 'kill':
2484
+ return cmd_kill(*argv[2:])
2104
2485
 
2105
2486
  # Hook commands
2106
2487
  elif cmd == 'post':
@@ -2112,6 +2493,9 @@ def main(argv=None):
2112
2493
  elif cmd == 'notify':
2113
2494
  handle_hook_notification()
2114
2495
  return 0
2496
+ elif cmd == 'pre':
2497
+ handle_hook_pretooluse()
2498
+ return 0
2115
2499
 
2116
2500
 
2117
2501
  # Unknown command