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/__init__.py +1 -1
- hcom/__main__.py +666 -282
- {hcom-0.1.8.dist-info → hcom-0.2.0.dist-info}/METADATA +30 -13
- hcom-0.2.0.dist-info/RECORD +7 -0
- hcom-0.1.8.dist-info/RECORD +0 -7
- {hcom-0.1.8.dist-info → hcom-0.2.0.dist-info}/WHEEL +0 -0
- {hcom-0.1.8.dist-info → hcom-0.2.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.1.8.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,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
|
|
1312
|
-
hcom watch --status Show status
|
|
1313
|
-
hcom watch --wait [
|
|
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
|
|
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
|
-
•
|
|
1329
|
-
•
|
|
1330
|
-
• hcom
|
|
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
|
|
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
|
|
1495
|
+
• ◉ waiting - instance is waiting for new messages
|
|
1347
1496
|
• ■ blocked - instance is blocked by permission request (needs user approval)
|
|
1348
|
-
• ○ inactive - instance is
|
|
1497
|
+
• ○ inactive - instance is timed out, disconnected, etc
|
|
1349
1498
|
|
|
1350
1499
|
CONFIG:
|
|
1351
|
-
|
|
1352
|
-
Config file (persistent): ~/.hcom/config.json
|
|
1500
|
+
Config file (persistent s): ~/.hcom/config.json
|
|
1353
1501
|
|
|
1354
|
-
Key settings (
|
|
1355
|
-
terminal_mode: "new_window" | "same_terminal" | "show_commands"
|
|
1356
|
-
initial_prompt: "Say hi in chat", first_use_text: "Essential
|
|
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
|
-
|
|
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
|
-
|
|
1546
|
+
instances_dir = get_hcom_dir() / 'instances'
|
|
1389
1547
|
|
|
1390
1548
|
if not log_file.exists():
|
|
1391
1549
|
log_file.touch()
|
|
1392
|
-
if not
|
|
1393
|
-
|
|
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
|
|
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
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
1668
|
+
instances_dir = get_hcom_dir() / 'instances'
|
|
1488
1669
|
|
|
1489
|
-
if not log_file.exists() and not
|
|
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
|
-
|
|
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
|
-
#
|
|
1511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1525
|
-
|
|
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
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
|
|
1918
|
+
clear_all_positions()
|
|
1679
1919
|
|
|
1680
1920
|
if archived:
|
|
1681
|
-
print(f"Archived
|
|
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
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2100
|
+
instances_dir = get_hcom_dir() / 'instances'
|
|
1838
2101
|
|
|
1839
|
-
if not log_file.exists() and not
|
|
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
|
|
2114
|
+
if mentions:
|
|
1852
2115
|
try:
|
|
1853
|
-
positions =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1996
|
-
|
|
1997
|
-
|
|
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':
|
|
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
|
-
|
|
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
|