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/__init__.py +1 -1
- hcom/__main__.py +700 -268
- {hcom-0.1.7.dist-info → hcom-0.2.0.dist-info}/METADATA +30 -13
- hcom-0.2.0.dist-info/RECORD +7 -0
- hcom-0.1.7.dist-info/RECORD +0 -7
- {hcom-0.1.7.dist-info → hcom-0.2.0.dist-info}/WHEEL +0 -0
- {hcom-0.1.7.dist-info → hcom-0.2.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.1.7.dist-info → hcom-0.2.0.dist-info}/top_level.txt +0 -0
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("
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
912
|
-
|
|
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
|
-
|
|
996
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
|
1249
|
-
"""
|
|
1367
|
+
def get_first_use_text(instance_name):
|
|
1368
|
+
"""Get first-use help text if not shown yet
|
|
1250
1369
|
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
|
1265
|
-
|
|
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
|
|
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
|
-
|
|
1546
|
+
instances_dir = get_hcom_dir() / 'instances'
|
|
1341
1547
|
|
|
1342
1548
|
if not log_file.exists():
|
|
1343
1549
|
log_file.touch()
|
|
1344
|
-
if not
|
|
1345
|
-
|
|
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
|
|
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
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1668
|
+
instances_dir = get_hcom_dir() / 'instances'
|
|
1440
1669
|
|
|
1441
|
-
if not log_file.exists() and not
|
|
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
|
-
|
|
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
|
-
#
|
|
1463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
1918
|
+
clear_all_positions()
|
|
1631
1919
|
|
|
1632
1920
|
if archived:
|
|
1633
|
-
print(f"Archived
|
|
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
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2100
|
+
instances_dir = get_hcom_dir() / 'instances'
|
|
1790
2101
|
|
|
1791
|
-
if not log_file.exists() and not
|
|
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
|
|
2114
|
+
if mentions:
|
|
1804
2115
|
try:
|
|
1805
|
-
positions =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1948
|
-
|
|
1949
|
-
|
|
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':
|
|
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
|
-
|
|
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
|