hcom 0.1.0__py3-none-any.whl

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

Potentially problematic release.


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

hcom/__main__.py ADDED
@@ -0,0 +1,1917 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Claude Hook Comms
4
+ A lightweight multi-agent communication and launching system for Claude instances
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import json
10
+ import tempfile
11
+ import shutil
12
+ import shlex
13
+ import re
14
+ import time
15
+ import select
16
+ import threading
17
+ import platform
18
+ from pathlib import Path
19
+ from datetime import datetime
20
+
21
+ # ==================== Constants ====================
22
+
23
+ IS_WINDOWS = sys.platform == 'win32'
24
+
25
+ HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
26
+ HCOM_ACTIVE_VALUE = '1'
27
+
28
+
29
+ EXIT_SUCCESS = 0
30
+ EXIT_ERROR = 1
31
+ EXIT_BLOCK = 2
32
+
33
+ HOOK_DECISION_BLOCK = 'block'
34
+
35
+ MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
36
+ TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
37
+
38
+ RESET = "\033[0m"
39
+ DIM = "\033[2m"
40
+ BOLD = "\033[1m"
41
+ FG_BLUE = "\033[34m"
42
+ FG_GREEN = "\033[32m"
43
+ FG_CYAN = "\033[36m"
44
+ FG_RED = "\033[31m"
45
+ FG_WHITE = "\033[37m"
46
+ FG_BLACK = "\033[30m"
47
+ BG_BLUE = "\033[44m"
48
+ BG_GREEN = "\033[42m"
49
+ BG_CYAN = "\033[46m"
50
+ BG_YELLOW = "\033[43m"
51
+ BG_RED = "\033[41m"
52
+
53
+ STATUS_MAP = {
54
+ "thinking": (BG_CYAN, "◉"),
55
+ "responding": (BG_GREEN, "▷"),
56
+ "executing": (BG_GREEN, "▶"),
57
+ "waiting": (BG_BLUE, "◉"),
58
+ "blocked": (BG_YELLOW, "■"),
59
+ "inactive": (BG_RED, "○")
60
+ }
61
+
62
+ # ==================== Configuration ====================
63
+
64
+ DEFAULT_CONFIG = {
65
+ "terminal_command": None,
66
+ "terminal_mode": "new_window",
67
+ "initial_prompt": "Say hi in chat",
68
+ "sender_name": "bigboss",
69
+ "sender_emoji": "🐳",
70
+ "cli_hints": "",
71
+ "wait_timeout": 600,
72
+ "max_message_size": 4096,
73
+ "max_messages_per_delivery": 20,
74
+ "first_use_text": "Essential, concise messages only, say hi in hcom chat now",
75
+ "instance_hints": "",
76
+ "env_overrides": {}
77
+ }
78
+
79
+ _config = None
80
+
81
+ HOOK_SETTINGS = {
82
+ 'wait_timeout': 'HCOM_WAIT_TIMEOUT',
83
+ 'max_message_size': 'HCOM_MAX_MESSAGE_SIZE',
84
+ 'max_messages_per_delivery': 'HCOM_MAX_MESSAGES_PER_DELIVERY',
85
+ 'first_use_text': 'HCOM_FIRST_USE_TEXT',
86
+ 'instance_hints': 'HCOM_INSTANCE_HINTS',
87
+ 'sender_name': 'HCOM_SENDER_NAME',
88
+ 'sender_emoji': 'HCOM_SENDER_EMOJI',
89
+ 'cli_hints': 'HCOM_CLI_HINTS',
90
+ 'terminal_mode': 'HCOM_TERMINAL_MODE',
91
+ 'terminal_command': 'HCOM_TERMINAL_COMMAND',
92
+ 'initial_prompt': 'HCOM_INITIAL_PROMPT'
93
+ }
94
+
95
+ # ==================== File System Utilities ====================
96
+
97
+ def get_hcom_dir():
98
+ """Get the hcom directory in user's home"""
99
+ return Path.home() / ".hcom"
100
+
101
+ def ensure_hcom_dir():
102
+ """Create the hcom directory if it doesn't exist"""
103
+ hcom_dir = get_hcom_dir()
104
+ hcom_dir.mkdir(exist_ok=True)
105
+ return hcom_dir
106
+
107
+ def atomic_write(filepath, content):
108
+ """Write content to file atomically to prevent corruption"""
109
+ filepath = Path(filepath)
110
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
111
+ tmp.write(content)
112
+ tmp.flush()
113
+ os.fsync(tmp.fileno())
114
+
115
+ os.replace(tmp.name, filepath)
116
+
117
+ # ==================== Configuration System ====================
118
+
119
+ def get_cached_config():
120
+ """Get cached configuration, loading if needed"""
121
+ global _config
122
+ if _config is None:
123
+ _config = _load_config_from_file()
124
+ return _config
125
+
126
+ def _load_config_from_file():
127
+ """Actually load configuration from ~/.hcom/config.json"""
128
+ ensure_hcom_dir()
129
+ config_path = get_hcom_dir() / 'config.json'
130
+
131
+ config = DEFAULT_CONFIG.copy()
132
+ config['env_overrides'] = DEFAULT_CONFIG['env_overrides'].copy()
133
+
134
+ if config_path.exists():
135
+ try:
136
+ with open(config_path, 'r') as f:
137
+ user_config = json.load(f)
138
+
139
+ for key, value in user_config.items():
140
+ if key == 'env_overrides':
141
+ config['env_overrides'].update(value)
142
+ else:
143
+ config[key] = value
144
+
145
+ except json.JSONDecodeError:
146
+ print(format_warning("Invalid JSON in config file, using defaults"), file=sys.stderr)
147
+ else:
148
+ atomic_write(config_path, json.dumps(DEFAULT_CONFIG, indent=2))
149
+
150
+ return config
151
+
152
+ def get_config_value(key, default=None):
153
+ """Get config value with proper precedence:
154
+ 1. Environment variable (if in HOOK_SETTINGS)
155
+ 2. Config file
156
+ 3. Default value
157
+ """
158
+ if key in HOOK_SETTINGS:
159
+ env_var = HOOK_SETTINGS[key]
160
+ env_value = os.environ.get(env_var)
161
+ if env_value is not None:
162
+ if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
163
+ try:
164
+ return int(env_value)
165
+ except ValueError:
166
+ pass
167
+ else:
168
+ return env_value
169
+
170
+ config = get_cached_config()
171
+ return config.get(key, default)
172
+
173
+ def build_claude_env():
174
+ """Build environment variables for Claude instances"""
175
+ env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
176
+
177
+ config = get_cached_config()
178
+ for config_key, env_var in HOOK_SETTINGS.items():
179
+ if config_key in config:
180
+ config_value = config[config_key]
181
+ default_value = DEFAULT_CONFIG.get(config_key)
182
+ if config_value != default_value:
183
+ env[env_var] = str(config_value)
184
+
185
+ env.update(config.get('env_overrides', {}))
186
+
187
+ return env
188
+
189
+ # ==================== Message System ====================
190
+
191
+ def validate_message(message):
192
+ """Validate message size and content"""
193
+ if not message or not message.strip():
194
+ return format_error("Message required")
195
+
196
+ max_size = get_config_value('max_message_size', 4096)
197
+ if len(message) > max_size:
198
+ return format_error(f"Message too large (max {max_size} chars)")
199
+
200
+ return None
201
+
202
+ def require_args(min_count, usage_msg, extra_msg=""):
203
+ """Check argument count and exit with usage if insufficient"""
204
+ if len(sys.argv) < min_count:
205
+ print(f"Usage: {usage_msg}")
206
+ if extra_msg:
207
+ print(extra_msg)
208
+ sys.exit(1)
209
+
210
+ def load_positions(pos_file):
211
+ """Load positions from file with error handling"""
212
+ positions = {}
213
+ if pos_file.exists():
214
+ try:
215
+ with open(pos_file, 'r') as f:
216
+ positions = json.load(f)
217
+ except (json.JSONDecodeError, FileNotFoundError):
218
+ pass
219
+ return positions
220
+
221
+ def send_message(from_instance, message):
222
+ """Send a message to the log"""
223
+ try:
224
+ ensure_hcom_dir()
225
+ log_file = get_hcom_dir() / "hcom.log"
226
+ pos_file = get_hcom_dir() / "hcom.json"
227
+
228
+ escaped_message = message.replace('|', '\\|')
229
+ escaped_from = from_instance.replace('|', '\\|')
230
+
231
+ timestamp = datetime.now().isoformat()
232
+ line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
233
+
234
+ with open(log_file, 'a') as f:
235
+ f.write(line)
236
+ f.flush()
237
+
238
+ return True
239
+ except Exception:
240
+ return False
241
+
242
+ def should_deliver_message(msg, instance_name):
243
+ """Check if message should be delivered based on @-mentions"""
244
+ text = msg['message']
245
+
246
+ if '@' not in text:
247
+ return True
248
+
249
+ mentions = MENTION_PATTERN.findall(text)
250
+
251
+ if not mentions:
252
+ return True
253
+
254
+ for mention in mentions:
255
+ if instance_name.lower().startswith(mention.lower()):
256
+ return True
257
+
258
+ return False
259
+
260
+ # ==================== Parsing and Helper Functions ====================
261
+
262
+ def parse_open_args(args):
263
+ """Parse arguments for open command
264
+
265
+ Returns:
266
+ tuple: (instances, prefix, claude_args)
267
+ instances: list of agent names or 'generic'
268
+ prefix: team name prefix or None
269
+ claude_args: additional args to pass to claude
270
+ """
271
+ instances = []
272
+ prefix = None
273
+ claude_args = []
274
+
275
+ i = 0
276
+ while i < len(args):
277
+ arg = args[i]
278
+
279
+ if arg == '--prefix':
280
+ if i + 1 >= len(args):
281
+ raise ValueError(format_error('--prefix requires an argument'))
282
+ prefix = args[i + 1]
283
+ if '|' in prefix:
284
+ raise ValueError(format_error('Team name cannot contain pipe characters'))
285
+ i += 2
286
+ elif arg == '--claude-args':
287
+ # Next argument contains claude args as a string
288
+ if i + 1 >= len(args):
289
+ raise ValueError(format_error('--claude-args requires an argument'))
290
+ claude_args = shlex.split(args[i + 1])
291
+ i += 2
292
+ else:
293
+ try:
294
+ count = int(arg)
295
+ if count < 0:
296
+ raise ValueError(format_error(f"Cannot launch negative instances: {count}"))
297
+ if count > 100:
298
+ raise ValueError(format_error(f"Too many instances requested: {count}", "Maximum 100 instances at once"))
299
+ instances.extend(['generic'] * count)
300
+ except ValueError as e:
301
+ if "Cannot launch" in str(e) or "Too many instances" in str(e):
302
+ raise
303
+ # Not a number, treat as agent name
304
+ instances.append(arg)
305
+ i += 1
306
+
307
+ if not instances:
308
+ instances = ['generic']
309
+
310
+ return instances, prefix, claude_args
311
+
312
+ def resolve_agent(name):
313
+ """Resolve agent file by name
314
+
315
+ Looks for agent files in:
316
+ 1. .claude/agents/{name}.md (local)
317
+ 2. ~/.claude/agents/{name}.md (global)
318
+
319
+ Returns the content after stripping YAML frontmatter
320
+ """
321
+ for base_path in [Path('.'), Path.home()]:
322
+ agent_path = base_path / '.claude/agents' / f'{name}.md'
323
+ if agent_path.exists():
324
+ content = agent_path.read_text()
325
+ stripped = strip_frontmatter(content)
326
+ if not stripped.strip():
327
+ raise ValueError(format_error(f"Agent '{name}' has empty content", 'Check the agent file contains a system prompt'))
328
+ return stripped
329
+
330
+ raise FileNotFoundError(format_error(f'Agent not found: {name}', 'Check available agents or create the agent file'))
331
+
332
+ def strip_frontmatter(content):
333
+ """Strip YAML frontmatter from agent file"""
334
+ if content.startswith('---'):
335
+ # Find the closing --- on its own line
336
+ lines = content.split('\n')
337
+ for i, line in enumerate(lines[1:], 1):
338
+ if line.strip() == '---':
339
+ return '\n'.join(lines[i+1:]).strip()
340
+ return content
341
+
342
+ def get_display_name(transcript_path, prefix=None):
343
+ """Get display name for instance"""
344
+ syls = ['ka', 'ko', 'ma', 'mo', 'na', 'no', 'ra', 'ro', 'sa', 'so', 'ta', 'to', 'va', 'vo', 'za', 'zo', 'be', 'de', 'fe', 'ge', 'le', 'me', 'ne', 're', 'se', 'te', 've', 'we', 'hi']
345
+ dir_name = Path.cwd().name
346
+ dir_chars = (dir_name + 'xx')[:2].lower() # Pad short names to ensure 2 chars
347
+
348
+ conversation_uuid = get_conversation_uuid(transcript_path)
349
+
350
+ if conversation_uuid:
351
+ hash_val = sum(ord(c) for c in conversation_uuid)
352
+ uuid_char = conversation_uuid[0]
353
+ base_name = f"{dir_chars}{syls[hash_val % len(syls)]}{uuid_char}"
354
+ else:
355
+ base_name = f"{dir_chars}claude"
356
+
357
+ if prefix:
358
+ return f"{prefix}-{base_name}"
359
+ return base_name
360
+
361
+ def _remove_hcom_hooks_from_settings(settings):
362
+ """Remove hcom hooks from settings dict"""
363
+ if 'hooks' not in settings:
364
+ return
365
+
366
+ for event in ['PostToolUse', 'Stop', 'Notification']:
367
+ if event not in settings['hooks']:
368
+ continue
369
+
370
+ settings['hooks'][event] = [
371
+ matcher for matcher in settings['hooks'][event]
372
+ if not any(
373
+ any(pattern in hook.get('command', '')
374
+ for pattern in ['hcom post', 'hcom stop', 'hcom notify',
375
+ 'hcom.py post', 'hcom.py stop', 'hcom.py notify',
376
+ 'hcom.py" post', 'hcom.py" stop', 'hcom.py" notify'])
377
+ for hook in matcher.get('hooks', []))
378
+ ]
379
+
380
+ if not settings['hooks'][event]:
381
+ del settings['hooks'][event]
382
+
383
+ if not settings['hooks']:
384
+ del settings['hooks']
385
+
386
+ def build_env_string(env_vars, format_type="bash"):
387
+ """Build environment variable string for different shells"""
388
+ if format_type == "bash_export":
389
+ # Properly escape values for bash
390
+ return ' '.join(f'export {k}={shlex.quote(str(v))};' for k, v in env_vars.items())
391
+ elif format_type == "powershell":
392
+ # PowerShell environment variable syntax
393
+ return ' ; '.join(f'$env:{k}="{str(v).replace('"', '`"')}"' for k, v in env_vars.items())
394
+ else:
395
+ return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
396
+
397
+ def format_error(message, suggestion=None):
398
+ """Format error message consistently"""
399
+ base = f"Error: {message}"
400
+ if suggestion:
401
+ base += f". {suggestion}"
402
+ return base
403
+
404
+ def format_warning(message):
405
+ """Format warning message consistently"""
406
+ return f"Warning: {message}"
407
+
408
+ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat"):
409
+ """Build Claude command with proper argument handling
410
+
411
+ Returns tuple: (command_string, temp_file_path_or_none)
412
+ For agent content, writes to temp file and uses cat to read it.
413
+ """
414
+ cmd_parts = ['claude']
415
+ temp_file_path = None
416
+
417
+ if claude_args:
418
+ for arg in claude_args:
419
+ cmd_parts.append(shlex.quote(arg))
420
+
421
+ if agent_content:
422
+ temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False,
423
+ prefix='hcom_agent_', dir=tempfile.gettempdir())
424
+ temp_file.write(agent_content)
425
+ temp_file.close()
426
+ temp_file_path = temp_file.name
427
+
428
+ if claude_args and any(arg in claude_args for arg in ['-p', '--print']):
429
+ flag = '--system-prompt'
430
+ else:
431
+ flag = '--append-system-prompt'
432
+
433
+ cmd_parts.append(flag)
434
+ if sys.platform == 'win32':
435
+ # PowerShell handles paths differently, quote with single quotes
436
+ escaped_path = temp_file_path.replace("'", "''")
437
+ cmd_parts.append(f"\"$(Get-Content '{escaped_path}' -Raw)\"")
438
+ else:
439
+ cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
440
+
441
+ if claude_args or agent_content:
442
+ cmd_parts.append('--')
443
+
444
+ # Quote initial prompt normally
445
+ cmd_parts.append(shlex.quote(initial_prompt))
446
+
447
+ return ' '.join(cmd_parts), temp_file_path
448
+
449
+ def escape_for_platform(text, platform_type):
450
+ """Centralized escaping for different platforms"""
451
+ if platform_type == 'applescript':
452
+ # AppleScript escaping for text within double quotes
453
+ # We need to escape backslashes first, then other special chars
454
+ return (text.replace('\\', '\\\\')
455
+ .replace('"', '\\"') # Escape double quotes
456
+ .replace('\n', '\\n') # Escape newlines
457
+ .replace('\r', '\\r') # Escape carriage returns
458
+ .replace('\t', '\\t')) # Escape tabs
459
+ elif platform_type == 'powershell':
460
+ # PowerShell escaping - use backticks for special chars
461
+ return text.replace('`', '``').replace('"', '`"').replace('$', '`$')
462
+ else: # POSIX/bash
463
+ return shlex.quote(text)
464
+
465
+ def safe_command_substitution(template, **substitutions):
466
+ """Safely substitute values into command templates with automatic quoting"""
467
+ result = template
468
+ for key, value in substitutions.items():
469
+ placeholder = f'{{{key}}}'
470
+ if placeholder in result:
471
+ # Auto-quote substitutions unless already quoted
472
+ if key == 'env':
473
+ # env_str is already properly quoted
474
+ quoted_value = str(value)
475
+ else:
476
+ quoted_value = shlex.quote(str(value))
477
+ result = result.replace(placeholder, quoted_value)
478
+ return result
479
+
480
+ def launch_terminal(command, env, config=None, cwd=None):
481
+ """Launch terminal with command
482
+
483
+ Args:
484
+ command: Either a string command or list of command parts
485
+ env: Environment variables to set
486
+ config: Configuration dict
487
+ cwd: Working directory
488
+ """
489
+ import subprocess
490
+
491
+ if config is None:
492
+ config = get_cached_config()
493
+
494
+ env_vars = os.environ.copy()
495
+ env_vars.update(env)
496
+
497
+ terminal_mode = get_config_value('terminal_mode', 'new_window')
498
+
499
+ # Command should now always be a string from build_claude_command
500
+ command_str = command
501
+
502
+ if terminal_mode == 'show_commands':
503
+ env_str = build_env_string(env)
504
+ print(f"{env_str} {command_str}")
505
+ return True
506
+
507
+ elif terminal_mode == 'same_terminal':
508
+ print(f"Launching Claude in current terminal...")
509
+ result = subprocess.run(command_str, shell=True, env=env_vars, cwd=cwd)
510
+ return result.returncode == 0
511
+
512
+ system = platform.system()
513
+
514
+ custom_cmd = get_config_value('terminal_command')
515
+ if custom_cmd and custom_cmd != 'None' and custom_cmd != 'null':
516
+ # Replace placeholders
517
+ env_str = build_env_string(env)
518
+ working_dir = cwd or os.getcwd()
519
+
520
+ final_cmd = safe_command_substitution(
521
+ custom_cmd,
522
+ cmd=command_str,
523
+ env=env_str, # Already quoted
524
+ cwd=working_dir
525
+ )
526
+
527
+ result = subprocess.run(final_cmd, shell=True, capture_output=True)
528
+ if result.returncode != 0:
529
+ raise subprocess.CalledProcessError(result.returncode, final_cmd, result.stderr)
530
+ return True
531
+
532
+ if system == 'Darwin': # macOS
533
+ env_setup = build_env_string(env, "bash_export")
534
+ # Include cd command if cwd is specified
535
+ if cwd:
536
+ full_cmd = f'cd {shlex.quote(cwd)}; {env_setup} {command_str}'
537
+ else:
538
+ full_cmd = f'{env_setup} {command_str}'
539
+
540
+ # Escape the command for AppleScript double-quoted string
541
+ escaped = escape_for_platform(full_cmd, 'applescript')
542
+
543
+ script = f'tell app "Terminal" to do script "{escaped}"'
544
+ subprocess.run(['osascript', '-e', script])
545
+ return True
546
+
547
+ elif system == 'Linux':
548
+ terminals = [
549
+ ('gnome-terminal', ['gnome-terminal', '--', 'bash', '-c']),
550
+ ('konsole', ['konsole', '-e', 'bash', '-c']),
551
+ ('xterm', ['xterm', '-e', 'bash', '-c'])
552
+ ]
553
+
554
+ for term_name, term_cmd in terminals:
555
+ if shutil.which(term_name):
556
+ env_cmd = build_env_string(env)
557
+ # Include cd command if cwd is specified
558
+ if cwd:
559
+ full_cmd = f'cd "{cwd}"; {env_cmd} {command_str}; exec bash'
560
+ else:
561
+ full_cmd = f'{env_cmd} {command_str}; exec bash'
562
+ subprocess.run(term_cmd + [full_cmd])
563
+ return True
564
+
565
+ raise Exception(format_error("No supported terminal emulator found", "Install gnome-terminal, konsole, xfce4-terminal, or xterm"))
566
+
567
+ elif system == 'Windows':
568
+ # Windows Terminal with PowerShell
569
+ env_setup = build_env_string(env, "powershell")
570
+ # Include cd command if cwd is specified
571
+ if cwd:
572
+ full_cmd = f'cd "{cwd}" ; {env_setup} ; {command_str}'
573
+ else:
574
+ full_cmd = f'{env_setup} ; {command_str}'
575
+
576
+ try:
577
+ # Try Windows Terminal with PowerShell
578
+ subprocess.run(['wt', 'powershell', '-NoExit', '-Command', full_cmd])
579
+ except FileNotFoundError:
580
+ # Fallback to PowerShell directly
581
+ subprocess.run(['powershell', '-NoExit', '-Command', full_cmd])
582
+ return True
583
+
584
+ else:
585
+ raise Exception(format_error(f"Unsupported platform: {system}", "Supported platforms: macOS, Linux, Windows"))
586
+
587
+ def setup_hooks():
588
+ """Set up Claude hooks in current directory"""
589
+ claude_dir = Path.cwd() / '.claude'
590
+ claude_dir.mkdir(exist_ok=True)
591
+
592
+ settings_path = claude_dir / 'settings.local.json'
593
+ settings = {}
594
+
595
+ if settings_path.exists():
596
+ try:
597
+ with open(settings_path, 'r') as f:
598
+ settings = json.load(f)
599
+ except json.JSONDecodeError:
600
+ settings = {}
601
+
602
+ if 'hooks' not in settings:
603
+ settings['hooks'] = {}
604
+ if 'permissions' not in settings:
605
+ settings['permissions'] = {}
606
+ if 'allow' not in settings['permissions']:
607
+ settings['permissions']['allow'] = []
608
+
609
+ _remove_hcom_hooks_from_settings(settings)
610
+
611
+ if 'hooks' not in settings:
612
+ settings['hooks'] = {}
613
+
614
+ hcom_send_permission = 'Bash(echo "HCOM_SEND:*")'
615
+ if hcom_send_permission not in settings['permissions']['allow']:
616
+ settings['permissions']['allow'].append(hcom_send_permission)
617
+
618
+ # Detect hcom executable path
619
+ try:
620
+ import subprocess
621
+ subprocess.run(['hcom', '--help'], capture_output=True, check=True)
622
+ hcom_cmd = 'hcom'
623
+ except (subprocess.CalledProcessError, FileNotFoundError):
624
+ # Use full path to current script
625
+ hcom_cmd = f'"{sys.executable}" "{os.path.abspath(__file__)}"'
626
+
627
+ # Add PostToolUse hook
628
+ if 'PostToolUse' not in settings['hooks']:
629
+ settings['hooks']['PostToolUse'] = []
630
+
631
+ settings['hooks']['PostToolUse'].append({
632
+ 'matcher': '.*',
633
+ 'hooks': [{
634
+ 'type': 'command',
635
+ 'command': f'{hcom_cmd} post'
636
+ }],
637
+ 'timeout': 10 # 10 second timeout for PostToolUse
638
+ })
639
+
640
+ # Add Stop hook
641
+ if 'Stop' not in settings['hooks']:
642
+ settings['hooks']['Stop'] = []
643
+
644
+ wait_timeout = get_config_value('wait_timeout', 600)
645
+
646
+ settings['hooks']['Stop'].append({
647
+ 'matcher': '',
648
+ 'hooks': [{
649
+ 'type': 'command',
650
+ 'command': f'{hcom_cmd} stop',
651
+ 'timeout': wait_timeout
652
+ }]
653
+ })
654
+
655
+ # Add Notification hook
656
+ if 'Notification' not in settings['hooks']:
657
+ settings['hooks']['Notification'] = []
658
+
659
+ settings['hooks']['Notification'].append({
660
+ 'matcher': '',
661
+ 'hooks': [{
662
+ 'type': 'command',
663
+ 'command': f'{hcom_cmd} notify'
664
+ }]
665
+ })
666
+
667
+ # Write settings atomically
668
+ atomic_write(settings_path, json.dumps(settings, indent=2))
669
+
670
+ return True
671
+
672
+ def is_interactive():
673
+ """Check if running in interactive mode"""
674
+ return sys.stdin.isatty() and sys.stdout.isatty()
675
+
676
+ def get_archive_timestamp():
677
+ """Get timestamp for archive files"""
678
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
679
+
680
+ def get_conversation_uuid(transcript_path):
681
+ """Get conversation UUID from transcript"""
682
+ try:
683
+ if not transcript_path or not os.path.exists(transcript_path):
684
+ return None
685
+
686
+ with open(transcript_path, 'r') as f:
687
+ first_line = f.readline().strip()
688
+ if first_line:
689
+ entry = json.loads(first_line)
690
+ return entry.get('uuid')
691
+ except Exception:
692
+ pass
693
+ return None
694
+
695
+ def is_parent_alive(parent_pid=None):
696
+ """Check if parent process is alive"""
697
+ if parent_pid is None:
698
+ parent_pid = os.getppid()
699
+
700
+ if IS_WINDOWS:
701
+ try:
702
+ import ctypes
703
+ kernel32 = ctypes.windll.kernel32
704
+ handle = kernel32.OpenProcess(0x0400, False, parent_pid)
705
+ if handle == 0:
706
+ return False
707
+ kernel32.CloseHandle(handle)
708
+ return True
709
+ except Exception:
710
+ return True
711
+ else:
712
+ try:
713
+ os.kill(parent_pid, 0)
714
+ return True
715
+ except ProcessLookupError:
716
+ return False
717
+ except Exception:
718
+ return True
719
+
720
+ def parse_log_messages(log_file, start_pos=0):
721
+ """Parse messages from log file"""
722
+ log_file = Path(log_file)
723
+ if not log_file.exists():
724
+ return []
725
+
726
+ messages = []
727
+ with open(log_file, 'r') as f:
728
+ f.seek(start_pos)
729
+ content = f.read()
730
+
731
+ if not content.strip():
732
+ return []
733
+
734
+ message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
735
+
736
+ for entry in message_entries:
737
+ if not entry or '|' not in entry:
738
+ continue
739
+
740
+ parts = entry.split('|', 2)
741
+ if len(parts) == 3:
742
+ timestamp, from_instance, message = parts
743
+ messages.append({
744
+ 'timestamp': timestamp,
745
+ 'from': from_instance.replace('\\|', '|'),
746
+ 'message': message.replace('\\|', '|')
747
+ })
748
+
749
+ return messages
750
+
751
+ def get_new_messages(instance_name):
752
+ """Get new messages for instance with @-mention filtering"""
753
+ ensure_hcom_dir()
754
+ log_file = get_hcom_dir() / "hcom.log"
755
+ pos_file = get_hcom_dir() / "hcom.json"
756
+
757
+ if not log_file.exists():
758
+ return []
759
+
760
+ positions = load_positions(pos_file)
761
+
762
+ # Get last position for this instance
763
+ last_pos = 0
764
+ if instance_name in positions:
765
+ pos_data = positions.get(instance_name, {})
766
+ last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
767
+
768
+ all_messages = parse_log_messages(log_file, last_pos)
769
+
770
+ # Filter messages:
771
+ # 1. Exclude own messages
772
+ # 2. Apply @-mention filtering
773
+ messages = []
774
+ for msg in all_messages:
775
+ if msg['from'] != instance_name:
776
+ if should_deliver_message(msg, instance_name):
777
+ messages.append(msg)
778
+
779
+ # Update position to end of file
780
+ with open(log_file, 'r') as f:
781
+ f.seek(0, 2) # Seek to end
782
+ new_pos = f.tell()
783
+
784
+ # Update position file
785
+ if instance_name not in positions:
786
+ positions[instance_name] = {}
787
+
788
+ positions[instance_name]['pos'] = new_pos
789
+
790
+ atomic_write(pos_file, json.dumps(positions, indent=2))
791
+
792
+ return messages
793
+
794
+ def format_age(seconds):
795
+ """Format time ago in human readable form"""
796
+ if seconds < 60:
797
+ return f"{int(seconds)}s"
798
+ elif seconds < 3600:
799
+ return f"{int(seconds/60)}m"
800
+ else:
801
+ return f"{int(seconds/3600)}h"
802
+
803
+ def get_transcript_status(transcript_path):
804
+ """Parse transcript to determine current Claude state"""
805
+ try:
806
+ if not transcript_path or not os.path.exists(transcript_path):
807
+ return "inactive", "", "", 0
808
+
809
+ with open(transcript_path, 'r') as f:
810
+ lines = f.readlines()[-5:]
811
+
812
+ for line in reversed(lines):
813
+ entry = json.loads(line)
814
+ timestamp = datetime.fromisoformat(entry['timestamp']).timestamp()
815
+ age = int(time.time() - timestamp)
816
+
817
+ if entry['type'] == 'system':
818
+ content = entry.get('content', '')
819
+ if 'Running' in content:
820
+ tool_name = content.split('Running ')[1].split('[')[0].strip()
821
+ return "executing", f"({format_age(age)})", tool_name, timestamp
822
+
823
+ elif entry['type'] == 'assistant':
824
+ content = entry.get('content', [])
825
+ if any('tool_use' in str(item) for item in content):
826
+ return "executing", f"({format_age(age)})", "tool", timestamp
827
+ else:
828
+ return "responding", f"({format_age(age)})", "", timestamp
829
+
830
+ elif entry['type'] == 'user':
831
+ return "thinking", f"({format_age(age)})", "", timestamp
832
+
833
+ return "inactive", "", "", 0
834
+ except Exception:
835
+ return "inactive", "", "", 0
836
+
837
+ def get_instance_status(pos_data):
838
+ """Get current status of instance"""
839
+ now = int(time.time())
840
+ wait_timeout = get_config_value('wait_timeout', 600)
841
+
842
+ last_permission = pos_data.get("last_permission_request", 0)
843
+ last_stop = pos_data.get("last_stop", 0)
844
+ last_tool = pos_data.get("last_tool", 0)
845
+
846
+ transcript_timestamp = 0
847
+ transcript_status = "inactive"
848
+
849
+ transcript_path = pos_data.get("transcript_path", "")
850
+ if transcript_path:
851
+ status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
852
+ transcript_status = status
853
+
854
+ events = [
855
+ (last_permission, "blocked"),
856
+ (last_stop, "waiting"),
857
+ (last_tool, "inactive"),
858
+ (transcript_timestamp, transcript_status)
859
+ ]
860
+
861
+ recent_events = [(ts, status) for ts, status in events if ts > 0]
862
+ if not recent_events:
863
+ return "inactive", ""
864
+
865
+ most_recent_time, most_recent_status = max(recent_events)
866
+ age = now - most_recent_time
867
+
868
+ if age > wait_timeout:
869
+ return "inactive", ""
870
+
871
+ return most_recent_status, f"({format_age(age)})"
872
+
873
+ def get_status_block(status_type):
874
+ """Get colored status block for a status type"""
875
+ color, symbol = STATUS_MAP.get(status_type, (BG_RED, "?"))
876
+ text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
877
+ return f"{text_color}{BOLD}{color} {symbol} {RESET}"
878
+
879
+ def format_message_line(msg, truncate=False):
880
+ """Format a message for display"""
881
+ time_obj = datetime.fromisoformat(msg['timestamp'])
882
+ time_str = time_obj.strftime("%H:%M")
883
+
884
+ sender_name = get_config_value('sender_name', 'bigboss')
885
+ sender_emoji = get_config_value('sender_emoji', '🐳')
886
+
887
+ display_name = f"{sender_emoji} {msg['from']}" if msg['from'] == sender_name else msg['from']
888
+
889
+ if truncate:
890
+ sender = display_name[:10]
891
+ message = msg['message'][:50]
892
+ return f" {DIM}{time_str}{RESET} {BOLD}{sender}{RESET}: {message}"
893
+ else:
894
+ return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
895
+
896
+ def show_recent_messages(messages, limit=None, truncate=False):
897
+ """Show recent messages"""
898
+ if limit is None:
899
+ messages_to_show = messages
900
+ else:
901
+ start_idx = max(0, len(messages) - limit)
902
+ messages_to_show = messages[start_idx:]
903
+
904
+ for msg in messages_to_show:
905
+ print(format_message_line(msg, truncate))
906
+
907
+
908
+ def get_terminal_height():
909
+ """Get current terminal height"""
910
+ try:
911
+ return shutil.get_terminal_size().lines
912
+ except (AttributeError, OSError):
913
+ return 24
914
+
915
+ def show_recent_activity_alt_screen(limit=None):
916
+ """Show recent messages in alt screen format with dynamic height"""
917
+ if limit is None:
918
+ # Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
919
+ available_height = get_terminal_height() - 20
920
+ limit = max(2, available_height // 2)
921
+
922
+ log_file = get_hcom_dir() / 'hcom.log'
923
+ if log_file.exists():
924
+ messages = parse_log_messages(log_file)
925
+ show_recent_messages(messages, limit, truncate=True)
926
+
927
+ def show_instances_status():
928
+ """Show status of all instances"""
929
+ pos_file = get_hcom_dir() / "hcom.json"
930
+ if not pos_file.exists():
931
+ print(f" {DIM}No Claude instances connected{RESET}")
932
+ return
933
+
934
+ positions = load_positions(pos_file)
935
+ if not positions:
936
+ print(f" {DIM}No Claude instances connected{RESET}")
937
+ return
938
+
939
+ print("Instances in hcom:")
940
+ for instance_name, pos_data in positions.items():
941
+ status_type, age = get_instance_status(pos_data)
942
+ status_block = get_status_block(status_type)
943
+ directory = pos_data.get("directory", "unknown")
944
+ print(f" {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}{RESET} {directory}")
945
+
946
+ def show_instances_by_directory():
947
+ """Show instances organized by their working directories"""
948
+ pos_file = get_hcom_dir() / "hcom.json"
949
+ if not pos_file.exists():
950
+ print(f" {DIM}No Claude instances connected{RESET}")
951
+ return
952
+
953
+ positions = load_positions(pos_file)
954
+ if positions:
955
+ directories = {}
956
+ for instance_name, pos_data in positions.items():
957
+ directory = pos_data.get("directory", "unknown")
958
+ if directory not in directories:
959
+ directories[directory] = []
960
+ directories[directory].append((instance_name, pos_data))
961
+
962
+ for directory, instances in directories.items():
963
+ print(f" {directory}")
964
+ for instance_name, pos_data in instances:
965
+ status_type, age = get_instance_status(pos_data)
966
+ status_block = get_status_block(status_type)
967
+ last_tool = pos_data.get("last_tool", 0)
968
+ last_tool_name = pos_data.get("last_tool_name", "unknown")
969
+ last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
970
+
971
+ sid = pos_data.get("session_id", "")
972
+ session_info = f" | {sid}" if sid else ""
973
+
974
+ print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{session_info}{RESET}")
975
+ print()
976
+ else:
977
+ print(f" {DIM}Error reading instance data{RESET}")
978
+
979
+ def alt_screen_detailed_status_and_input():
980
+ """Show detailed status in alt screen and get user input"""
981
+ sys.stdout.write("\033[?1049h\033[2J\033[H")
982
+
983
+ try:
984
+ timestamp = datetime.now().strftime("%H:%M:%S")
985
+ print(f"{BOLD} HCOM DETAILED STATUS{RESET}")
986
+ print(f"{BOLD}{'=' * 70}{RESET}")
987
+ print(f"{FG_CYAN} HCOM: GLOBAL CHAT{RESET}")
988
+ print(f"{DIM} LOG FILE: {get_hcom_dir() / 'hcom.log'}{RESET}")
989
+ print(f"{DIM} UPDATED: {timestamp}{RESET}")
990
+ print(f"{BOLD}{'-' * 70}{RESET}")
991
+ print()
992
+
993
+ show_instances_by_directory()
994
+
995
+ print()
996
+ print(f"{BOLD} RECENT ACTIVITY:{RESET}")
997
+
998
+ show_recent_activity_alt_screen()
999
+
1000
+ print()
1001
+ print(f"{BOLD}{'-' * 70}{RESET}")
1002
+ print(f"{FG_GREEN} Type message and press Enter to send (empty to cancel):{RESET}")
1003
+ message = input(f"{FG_CYAN} > {RESET}")
1004
+
1005
+ print(f"{BOLD}{'=' * 70}{RESET}")
1006
+
1007
+ finally:
1008
+ sys.stdout.write("\033[?1049l")
1009
+
1010
+ return message
1011
+
1012
+ def get_status_summary():
1013
+ """Get a one-line summary of all instance statuses"""
1014
+ pos_file = get_hcom_dir() / "hcom.json"
1015
+ if not pos_file.exists():
1016
+ return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1017
+
1018
+ positions = load_positions(pos_file)
1019
+ if not positions:
1020
+ return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1021
+
1022
+ status_counts = {"thinking": 0, "responding": 0, "executing": 0, "waiting": 0, "blocked": 0, "inactive": 0}
1023
+
1024
+ for _, pos_data in positions.items():
1025
+ status_type, _ = get_instance_status(pos_data)
1026
+ if status_type in status_counts:
1027
+ status_counts[status_type] += 1
1028
+
1029
+ parts = []
1030
+ status_order = ["thinking", "responding", "executing", "waiting", "blocked", "inactive"]
1031
+
1032
+ for status_type in status_order:
1033
+ count = status_counts[status_type]
1034
+ if count > 0:
1035
+ color, symbol = STATUS_MAP[status_type]
1036
+ text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
1037
+ parts.append(f"{text_color}{BOLD}{color} {count} {symbol} {RESET}")
1038
+
1039
+ if parts:
1040
+ return "".join(parts)
1041
+ else:
1042
+ return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1043
+
1044
+ def update_status(s):
1045
+ """Update status line in place"""
1046
+ sys.stdout.write("\r\033[K" + s)
1047
+ sys.stdout.flush()
1048
+
1049
+ def log_line_with_status(message, status):
1050
+ """Print message and immediately restore status"""
1051
+ sys.stdout.write("\r\033[K" + message + "\n")
1052
+ sys.stdout.write("\033[K" + status)
1053
+ sys.stdout.flush()
1054
+
1055
+ def initialize_instance_in_position_file(instance_name, conversation_uuid=None):
1056
+ """Initialize an instance in the position file with all required fields"""
1057
+ ensure_hcom_dir()
1058
+ pos_file = get_hcom_dir() / "hcom.json"
1059
+ positions = load_positions(pos_file)
1060
+
1061
+ if instance_name not in positions:
1062
+ positions[instance_name] = {
1063
+ "pos": 0,
1064
+ "directory": str(Path.cwd()),
1065
+ "conversation_uuid": conversation_uuid or "unknown",
1066
+ "last_tool": 0,
1067
+ "last_tool_name": "unknown",
1068
+ "last_stop": 0,
1069
+ "last_permission_request": 0,
1070
+ "transcript_path": "",
1071
+ "session_id": "",
1072
+ "help_shown": False,
1073
+ "notification_message": ""
1074
+ }
1075
+ atomic_write(pos_file, json.dumps(positions, indent=2))
1076
+
1077
+ def migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path):
1078
+ """Migrate instance name from fallback to UUID-based if needed"""
1079
+ if instance_name.endswith("claude") and conversation_uuid:
1080
+ new_instance = get_display_name(transcript_path)
1081
+ if new_instance != instance_name and not new_instance.endswith("claude"):
1082
+ # Migrate from fallback name to UUID-based name
1083
+ pos_file = get_hcom_dir() / "hcom.json"
1084
+ positions = load_positions(pos_file)
1085
+ if instance_name in positions:
1086
+ # Copy over the old instance data to new name
1087
+ positions[new_instance] = positions.pop(instance_name)
1088
+ # Update the conversation UUID in the migrated data
1089
+ positions[new_instance]["conversation_uuid"] = conversation_uuid
1090
+ atomic_write(pos_file, json.dumps(positions, indent=2))
1091
+ # Instance name migrated
1092
+ return new_instance
1093
+ return instance_name
1094
+
1095
+ def update_instance_position(instance_name, update_fields):
1096
+ """Update instance position in position file"""
1097
+ ensure_hcom_dir()
1098
+ pos_file = get_hcom_dir() / "hcom.json"
1099
+
1100
+ # Get file modification time before reading to detect races
1101
+ mtime_before = pos_file.stat().st_mtime_ns if pos_file.exists() else 0
1102
+ positions = load_positions(pos_file)
1103
+
1104
+ # Get or create instance data
1105
+ if instance_name not in positions:
1106
+ positions[instance_name] = {}
1107
+
1108
+ # Update only provided fields
1109
+ for key, value in update_fields.items():
1110
+ positions[instance_name][key] = value
1111
+
1112
+ # Check if file was modified while we were working
1113
+ mtime_after = pos_file.stat().st_mtime_ns if pos_file.exists() else 0
1114
+ if mtime_after != mtime_before:
1115
+ # Someone else modified it, retry once
1116
+ return update_instance_position(instance_name, update_fields)
1117
+
1118
+ # Write back atomically
1119
+ atomic_write(pos_file, json.dumps(positions, indent=2))
1120
+
1121
+ def check_and_show_first_use_help(instance_name):
1122
+ """Check and show first-use help if needed"""
1123
+
1124
+ pos_file = get_hcom_dir() / "hcom.json"
1125
+ positions = load_positions(pos_file)
1126
+
1127
+ instance_data = positions.get(instance_name, {})
1128
+ if not instance_data.get('help_shown', False):
1129
+ # Mark help as shown
1130
+ update_instance_position(instance_name, {'help_shown': True})
1131
+
1132
+ # Get values using unified config system
1133
+ first_use_text = get_config_value('first_use_text', '')
1134
+ instance_hints = get_config_value('instance_hints', '')
1135
+
1136
+ help_text = f"Welcome! hcom chat active. Your alias: {instance_name}. " \
1137
+ f"Send messages: echo \"HCOM_SEND:your message\". " \
1138
+ f"{first_use_text} {instance_hints}".strip()
1139
+
1140
+ output = {"decision": HOOK_DECISION_BLOCK, "reason": help_text}
1141
+ print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1142
+ sys.exit(EXIT_BLOCK)
1143
+
1144
+ # ==================== Command Functions ====================
1145
+
1146
+ def show_main_screen_header():
1147
+ """Show header for main screen"""
1148
+ sys.stdout.write("\033[2J\033[H")
1149
+
1150
+ log_file = get_hcom_dir() / 'hcom.log'
1151
+ all_messages = []
1152
+ if log_file.exists():
1153
+ all_messages = parse_log_messages(log_file)
1154
+ message_count = len(all_messages)
1155
+
1156
+ print(f"\n{BOLD}{'='*50}{RESET}")
1157
+ print(f" {FG_CYAN}HCOM: global chat{RESET}")
1158
+
1159
+ status_line = get_status_summary()
1160
+ print(f" {BOLD}INSTANCES:{RESET} {status_line}")
1161
+ print(f" {DIM}LOGS: {log_file} ({message_count} messages){RESET}")
1162
+ print(f"{BOLD}{'='*50}{RESET}\n")
1163
+
1164
+ return all_messages
1165
+
1166
+ def cmd_help():
1167
+ """Show help text"""
1168
+ print("""hcom - Hook Communications
1169
+
1170
+ Usage:
1171
+ hcom open [n] Launch n Claude instances
1172
+ hcom open --prefix name n Launch with name prefix
1173
+ hcom watch View conversation dashboard
1174
+ hcom clear Clear and archive conversation
1175
+ hcom cleanup Remove hooks from current directory
1176
+ hcom cleanup --all Remove hooks from all tracked directories
1177
+ hcom help Show this help
1178
+
1179
+ Automation:
1180
+ hcom send 'msg' Send message
1181
+ hcom send '@prefix msg' Send to specific instances
1182
+ hcom watch --logs/--status Show logs or status""")
1183
+ return 0
1184
+
1185
+ def cmd_open(*args):
1186
+ """Launch Claude instances with chat enabled"""
1187
+ try:
1188
+ # Parse arguments
1189
+ instances, prefix, claude_args = parse_open_args(list(args))
1190
+
1191
+ terminal_mode = get_config_value('terminal_mode', 'new_window')
1192
+
1193
+ # Fail fast for same_terminal with multiple instances
1194
+ if terminal_mode == 'same_terminal' and len(instances) > 1:
1195
+ print(format_error(
1196
+ f"same_terminal mode cannot launch {len(instances)} instances",
1197
+ "Use 'hcom open' for one generic instance or 'hcom open <agent>' for one agent"
1198
+ ), file=sys.stderr)
1199
+ return 1
1200
+
1201
+ try:
1202
+ setup_hooks()
1203
+ except Exception as e:
1204
+ print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
1205
+ return 1
1206
+
1207
+ ensure_hcom_dir()
1208
+ log_file = get_hcom_dir() / 'hcom.log'
1209
+ pos_file = get_hcom_dir() / 'hcom.json'
1210
+
1211
+ if not log_file.exists():
1212
+ log_file.touch()
1213
+ if not pos_file.exists():
1214
+ atomic_write(pos_file, json.dumps({}, indent=2))
1215
+
1216
+ # Build environment variables for Claude instances
1217
+ base_env = build_claude_env()
1218
+
1219
+ # Add prefix-specific hints if provided
1220
+ if prefix:
1221
+ hint = f"To respond to {prefix} group: echo \"HCOM_SEND:@{prefix} message\""
1222
+ base_env['HCOM_INSTANCE_HINTS'] = hint
1223
+
1224
+ first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
1225
+ base_env['HCOM_FIRST_USE_TEXT'] = first_use
1226
+
1227
+ launched = 0
1228
+ initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
1229
+
1230
+ temp_files_to_cleanup = []
1231
+
1232
+ for instance_type in instances:
1233
+ # Build claude command
1234
+ if instance_type == 'generic':
1235
+ # Generic instance - no agent content
1236
+ claude_cmd, temp_file = build_claude_command(
1237
+ agent_content=None,
1238
+ claude_args=claude_args,
1239
+ initial_prompt=initial_prompt
1240
+ )
1241
+ else:
1242
+ # Agent instance
1243
+ try:
1244
+ agent_content = resolve_agent(instance_type)
1245
+ claude_cmd, temp_file = build_claude_command(
1246
+ agent_content=agent_content,
1247
+ claude_args=claude_args,
1248
+ initial_prompt=initial_prompt
1249
+ )
1250
+ if temp_file:
1251
+ temp_files_to_cleanup.append(temp_file)
1252
+ except (FileNotFoundError, ValueError) as e:
1253
+ print(str(e), file=sys.stderr)
1254
+ continue
1255
+
1256
+ try:
1257
+ launch_terminal(claude_cmd, base_env, cwd=os.getcwd())
1258
+ launched += 1
1259
+ except Exception as e:
1260
+ print(f"Error: Failed to launch terminal: {e}", file=sys.stderr)
1261
+
1262
+ # Clean up temp files after a delay (let terminals read them first)
1263
+ if temp_files_to_cleanup:
1264
+ def cleanup_temp_files():
1265
+ time.sleep(5) # Give terminals time to read the files
1266
+ for temp_file in temp_files_to_cleanup:
1267
+ try:
1268
+ os.unlink(temp_file)
1269
+ except:
1270
+ pass
1271
+
1272
+ cleanup_thread = threading.Thread(target=cleanup_temp_files)
1273
+ cleanup_thread.daemon = True
1274
+ cleanup_thread.start()
1275
+
1276
+ if launched == 0:
1277
+ print(format_error("No instances launched"), file=sys.stderr)
1278
+ return 1
1279
+
1280
+ # Success message
1281
+ print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
1282
+
1283
+ tips = [
1284
+ "Run 'hcom watch' to view/send in conversation dashboard",
1285
+ ]
1286
+ if prefix:
1287
+ tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
1288
+
1289
+ print("\n" + "\n".join(f" • {tip}" for tip in tips))
1290
+
1291
+ return 0
1292
+
1293
+ except ValueError as e:
1294
+ print(f"Error: {e}", file=sys.stderr)
1295
+ return 1
1296
+ except Exception as e:
1297
+ print(f"Error: {e}", file=sys.stderr)
1298
+ return 1
1299
+
1300
+ def cmd_watch(*args):
1301
+ """View conversation dashboard"""
1302
+ log_file = get_hcom_dir() / 'hcom.log'
1303
+ pos_file = get_hcom_dir() / 'hcom.json'
1304
+
1305
+ if not log_file.exists() and not pos_file.exists():
1306
+ print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
1307
+ return 1
1308
+
1309
+ # Parse arguments
1310
+ show_logs = False
1311
+ show_status = False
1312
+ wait_timeout = None
1313
+
1314
+ for arg in args:
1315
+ if arg == '--logs':
1316
+ show_logs = True
1317
+ elif arg == '--status':
1318
+ show_status = True
1319
+ elif arg.startswith('--wait='):
1320
+ try:
1321
+ wait_timeout = int(arg.split('=')[1])
1322
+ except ValueError:
1323
+ print(format_error("Invalid timeout value"), file=sys.stderr)
1324
+ return 1
1325
+ elif arg == '--wait':
1326
+ # Default wait timeout if no value provided
1327
+ wait_timeout = 60
1328
+
1329
+ # Non-interactive mode (no TTY or flags specified)
1330
+ if not is_interactive() or show_logs or show_status:
1331
+ if show_logs:
1332
+ if log_file.exists():
1333
+ messages = parse_log_messages(log_file)
1334
+ for msg in messages:
1335
+ print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1336
+ else:
1337
+ print("No messages yet")
1338
+
1339
+ if wait_timeout is not None:
1340
+ start_time = time.time()
1341
+ last_pos = log_file.stat().st_size if log_file.exists() else 0
1342
+
1343
+ while time.time() - start_time < wait_timeout:
1344
+ if log_file.exists() and log_file.stat().st_size > last_pos:
1345
+ new_messages = parse_log_messages(log_file, last_pos)
1346
+ for msg in new_messages:
1347
+ print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1348
+ last_pos = log_file.stat().st_size
1349
+ break
1350
+ time.sleep(1)
1351
+
1352
+ elif show_status:
1353
+ print("HCOM STATUS")
1354
+ print("INSTANCES:")
1355
+ show_instances_status()
1356
+ print("\nRECENT ACTIVITY:")
1357
+ show_recent_activity_alt_screen()
1358
+ print(f"\nLOG FILE: {log_file}")
1359
+ else:
1360
+ # No TTY - show automation usage
1361
+ print("Automation usage:")
1362
+ print(" hcom send 'message' Send message to group")
1363
+ print(" hcom watch --logs Show message history")
1364
+ print(" hcom watch --status Show instance status")
1365
+
1366
+ return 0
1367
+
1368
+ # Interactive dashboard mode
1369
+ last_pos = 0
1370
+ status_suffix = f"{DIM} [⏎] chat...{RESET}"
1371
+
1372
+ all_messages = show_main_screen_header()
1373
+
1374
+ show_recent_messages(all_messages, limit=5)
1375
+ print(f"\n{DIM}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
1376
+
1377
+ if log_file.exists():
1378
+ last_pos = log_file.stat().st_size
1379
+
1380
+ # Print newline to ensure status starts on its own line
1381
+ print()
1382
+
1383
+ current_status = get_status_summary()
1384
+ update_status(f"{current_status}{status_suffix}")
1385
+ last_status_update = time.time()
1386
+
1387
+ last_status = current_status
1388
+
1389
+ try:
1390
+ while True:
1391
+ now = time.time()
1392
+ if now - last_status_update > 2.0:
1393
+ current_status = get_status_summary()
1394
+
1395
+ # Only redraw if status text changed
1396
+ if current_status != last_status:
1397
+ update_status(f"{current_status}{status_suffix}")
1398
+ last_status = current_status
1399
+
1400
+ last_status_update = now
1401
+
1402
+ if log_file.exists() and log_file.stat().st_size > last_pos:
1403
+ new_messages = parse_log_messages(log_file, last_pos)
1404
+ # Use the last known status for consistency
1405
+ status_line_text = f"{last_status}{status_suffix}"
1406
+ for msg in new_messages:
1407
+ log_line_with_status(format_message_line(msg), status_line_text)
1408
+ last_pos = log_file.stat().st_size
1409
+
1410
+ # Check for keyboard input
1411
+ ready_for_input = False
1412
+ if IS_WINDOWS:
1413
+ import msvcrt
1414
+ if msvcrt.kbhit():
1415
+ msvcrt.getch()
1416
+ ready_for_input = True
1417
+ else:
1418
+ if select.select([sys.stdin], [], [], 0.1)[0]:
1419
+ sys.stdin.readline()
1420
+ ready_for_input = True
1421
+
1422
+ if ready_for_input:
1423
+ sys.stdout.write("\r\033[K")
1424
+
1425
+ message = alt_screen_detailed_status_and_input()
1426
+
1427
+ all_messages = show_main_screen_header()
1428
+ show_recent_messages(all_messages)
1429
+ print(f"\n{DIM}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
1430
+
1431
+ if log_file.exists():
1432
+ last_pos = log_file.stat().st_size
1433
+
1434
+ if message and message.strip():
1435
+ sender_name = get_config_value('sender_name', 'bigboss')
1436
+ send_message(sender_name, message.strip())
1437
+ print(f"{FG_GREEN}✓ Sent{RESET}")
1438
+
1439
+ print()
1440
+
1441
+ current_status = get_status_summary()
1442
+ update_status(f"{current_status}{status_suffix}")
1443
+
1444
+ time.sleep(0.1)
1445
+
1446
+ except KeyboardInterrupt:
1447
+ sys.stdout.write("\033[?1049l\r\033[K")
1448
+ print(f"\n{DIM}[stopped]{RESET}")
1449
+
1450
+ return 0
1451
+
1452
+ def cmd_clear():
1453
+ """Clear and archive conversation"""
1454
+ ensure_hcom_dir()
1455
+ log_file = get_hcom_dir() / 'hcom.log'
1456
+ pos_file = get_hcom_dir() / 'hcom.json'
1457
+
1458
+ # Check if hcom files exist
1459
+ if not log_file.exists() and not pos_file.exists():
1460
+ print("No hcom conversation to clear")
1461
+ return 0
1462
+
1463
+ # Generate archive timestamp
1464
+ timestamp = get_archive_timestamp()
1465
+
1466
+ # Archive existing files if they have content
1467
+ archived = False
1468
+
1469
+ try:
1470
+ # Archive log file if it exists and has content
1471
+ if log_file.exists() and log_file.stat().st_size > 0:
1472
+ archive_log = get_hcom_dir() / f'hcom-{timestamp}.log'
1473
+ log_file.rename(archive_log)
1474
+ archived = True
1475
+ elif log_file.exists():
1476
+ log_file.unlink()
1477
+
1478
+ # Archive position file if it exists and has content
1479
+ if pos_file.exists():
1480
+ try:
1481
+ with open(pos_file, 'r') as f:
1482
+ data = json.load(f)
1483
+ if data: # Non-empty position file
1484
+ archive_pos = get_hcom_dir() / f'hcom-{timestamp}.json'
1485
+ pos_file.rename(archive_pos)
1486
+ archived = True
1487
+ else:
1488
+ pos_file.unlink()
1489
+ except (json.JSONDecodeError, FileNotFoundError):
1490
+ if pos_file.exists():
1491
+ pos_file.unlink()
1492
+
1493
+ log_file.touch()
1494
+ atomic_write(pos_file, json.dumps({}, indent=2))
1495
+
1496
+ if archived:
1497
+ print(f"Archived conversations to hcom-{timestamp}")
1498
+ print("Started fresh hcom conversation")
1499
+ return 0
1500
+
1501
+ except Exception as e:
1502
+ print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
1503
+ return 1
1504
+
1505
+ def cleanup_directory_hooks(directory):
1506
+ """Remove hcom hooks from a specific directory
1507
+ Returns tuple: (exit_code, message)
1508
+ exit_code: 0 for success, 1 for error
1509
+ message: what happened
1510
+ """
1511
+ settings_path = Path(directory) / '.claude' / 'settings.local.json'
1512
+
1513
+ if not settings_path.exists():
1514
+ return 0, "No Claude settings found"
1515
+
1516
+ try:
1517
+ # Load existing settings
1518
+ with open(settings_path, 'r') as f:
1519
+ settings = json.load(f)
1520
+
1521
+ # Check if any hcom hooks exist
1522
+ hooks_found = False
1523
+
1524
+ original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
1525
+ for event in ['PostToolUse', 'Stop', 'Notification'])
1526
+
1527
+ _remove_hcom_hooks_from_settings(settings)
1528
+
1529
+ # Check if any were removed
1530
+ new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
1531
+ for event in ['PostToolUse', 'Stop', 'Notification'])
1532
+ if new_hook_count < original_hook_count:
1533
+ hooks_found = True
1534
+
1535
+ if 'permissions' in settings and 'allow' in settings['permissions']:
1536
+ original_perms = settings['permissions']['allow'][:]
1537
+ settings['permissions']['allow'] = [
1538
+ perm for perm in settings['permissions']['allow']
1539
+ if 'HCOM_SEND' not in perm
1540
+ ]
1541
+
1542
+ if len(settings['permissions']['allow']) < len(original_perms):
1543
+ hooks_found = True
1544
+
1545
+ if not settings['permissions']['allow']:
1546
+ del settings['permissions']['allow']
1547
+ if not settings['permissions']:
1548
+ del settings['permissions']
1549
+
1550
+ if not hooks_found:
1551
+ return 0, "No hcom hooks found"
1552
+
1553
+ # Write back or delete settings
1554
+ if not settings or (len(settings) == 0):
1555
+ # Delete empty settings file
1556
+ settings_path.unlink()
1557
+ return 0, "Removed hcom hooks (settings file deleted)"
1558
+ else:
1559
+ # Write updated settings
1560
+ atomic_write(settings_path, json.dumps(settings, indent=2))
1561
+ return 0, "Removed hcom hooks from settings"
1562
+
1563
+ except json.JSONDecodeError:
1564
+ return 1, format_error("Corrupted settings.local.json file")
1565
+ except Exception as e:
1566
+ return 1, format_error(f"Cannot modify settings.local.json: {e}")
1567
+
1568
+ def cmd_cleanup(*args):
1569
+ """Remove hcom hooks from current directory or all directories"""
1570
+ if args and args[0] == '--all':
1571
+ directories = set()
1572
+
1573
+ # Get all directories from current position file
1574
+ pos_file = get_hcom_dir() / 'hcom.json'
1575
+ if pos_file.exists():
1576
+ try:
1577
+ positions = load_positions(pos_file)
1578
+ for instance_data in positions.values():
1579
+ if isinstance(instance_data, dict) and 'directory' in instance_data:
1580
+ directories.add(instance_data['directory'])
1581
+ except Exception as e:
1582
+ print(format_warning(f"Could not read current position file: {e}"))
1583
+
1584
+ hcom_dir = get_hcom_dir()
1585
+ try:
1586
+ # Look for archived position files (hcom-TIMESTAMP.json)
1587
+ for archive_file in hcom_dir.glob('hcom-*.json'):
1588
+ try:
1589
+ with open(archive_file, 'r') as f:
1590
+ archived_positions = json.load(f)
1591
+ for instance_data in archived_positions.values():
1592
+ if isinstance(instance_data, dict) and 'directory' in instance_data:
1593
+ directories.add(instance_data['directory'])
1594
+ except Exception as e:
1595
+ print(format_warning(f"Could not read archive {archive_file.name}: {e}"))
1596
+ except Exception as e:
1597
+ print(format_warning(f"Could not scan for archived files: {e}"))
1598
+
1599
+ if not directories:
1600
+ print("No directories found in hcom tracking (current or archived)")
1601
+ return 0
1602
+
1603
+ print(f"Found {len(directories)} unique directories to check")
1604
+ cleaned = 0
1605
+ failed = 0
1606
+ already_clean = 0
1607
+
1608
+ for directory in sorted(directories):
1609
+ # Check if directory exists
1610
+ if not Path(directory).exists():
1611
+ print(f"\nSkipping {directory} (directory no longer exists)")
1612
+ continue
1613
+
1614
+ print(f"\nChecking {directory}...")
1615
+
1616
+ # Check if settings file exists
1617
+ settings_path = Path(directory) / '.claude' / 'settings.local.json'
1618
+ if not settings_path.exists():
1619
+ print(" No Claude settings found")
1620
+ already_clean += 1
1621
+ continue
1622
+
1623
+ exit_code, message = cleanup_directory_hooks(Path(directory))
1624
+ if exit_code == 0:
1625
+ if "No hcom hooks found" in message:
1626
+ already_clean += 1
1627
+ print(f" {message}")
1628
+ else:
1629
+ cleaned += 1
1630
+ print(f" {message}")
1631
+ else:
1632
+ failed += 1
1633
+ print(f" {message}")
1634
+
1635
+ print(f"\nSummary:")
1636
+ print(f" Cleaned: {cleaned} directories")
1637
+ print(f" Already clean: {already_clean} directories")
1638
+ if failed > 0:
1639
+ print(f" Failed: {failed} directories")
1640
+ return 1
1641
+ return 0
1642
+
1643
+ else:
1644
+ exit_code, message = cleanup_directory_hooks(Path.cwd())
1645
+ print(message)
1646
+ return exit_code
1647
+
1648
+ def cmd_send(message):
1649
+ """Send message to hcom"""
1650
+ # Check if hcom files exist
1651
+ log_file = get_hcom_dir() / 'hcom.log'
1652
+ pos_file = get_hcom_dir() / 'hcom.json'
1653
+
1654
+ if not log_file.exists() and not pos_file.exists():
1655
+ print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
1656
+ return 1
1657
+
1658
+ # Validate message
1659
+ error = validate_message(message)
1660
+ if error:
1661
+ print(f"Error: {error}", file=sys.stderr)
1662
+ return 1
1663
+
1664
+ # Send message
1665
+ sender_name = get_config_value('sender_name', 'bigboss')
1666
+
1667
+ if send_message(sender_name, message):
1668
+ print("Message sent")
1669
+ return 0
1670
+ else:
1671
+ print(format_error("Failed to send message"), file=sys.stderr)
1672
+ return 1
1673
+
1674
+ # ==================== Hook Functions ====================
1675
+
1676
+ def handle_hook_post():
1677
+ """Handle PostToolUse hook"""
1678
+ # Check if active
1679
+ if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
1680
+ sys.exit(EXIT_SUCCESS)
1681
+
1682
+ try:
1683
+ # Read JSON input
1684
+ hook_data = json.load(sys.stdin)
1685
+ transcript_path = hook_data.get('transcript_path', '')
1686
+ instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
1687
+ conversation_uuid = get_conversation_uuid(transcript_path)
1688
+
1689
+ # Migrate instance name if needed (from fallback to UUID-based)
1690
+ instance_name = migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path)
1691
+
1692
+ # Initialize instance if needed
1693
+ if not instance_name.endswith("claude") or conversation_uuid:
1694
+ initialize_instance_in_position_file(instance_name, conversation_uuid)
1695
+
1696
+ # Update instance position
1697
+ update_instance_position(instance_name, {
1698
+ 'last_tool': int(time.time()),
1699
+ 'last_tool_name': hook_data.get('tool_name', 'unknown'),
1700
+ 'session_id': hook_data.get('session_id', ''),
1701
+ 'transcript_path': transcript_path,
1702
+ 'conversation_uuid': conversation_uuid or 'unknown',
1703
+ 'directory': str(Path.cwd())
1704
+ })
1705
+
1706
+ # Check for HCOM_SEND in Bash commands
1707
+ if hook_data.get('tool_name') == 'Bash':
1708
+ command = hook_data.get('tool_input', {}).get('command', '')
1709
+ if 'HCOM_SEND:' in command:
1710
+ # Extract message after HCOM_SEND:
1711
+ parts = command.split('HCOM_SEND:', 1)
1712
+ if len(parts) > 1:
1713
+ remainder = parts[1]
1714
+
1715
+ # The message might be in the format:
1716
+ # - message" (from echo "HCOM_SEND:message")
1717
+ # - message' (from echo 'HCOM_SEND:message')
1718
+ # - message (from echo HCOM_SEND:message)
1719
+ # - "message" (from echo HCOM_SEND:"message")
1720
+
1721
+ message = remainder.strip()
1722
+
1723
+ # If it starts and ends with matching quotes, remove them
1724
+ if len(message) >= 2 and \
1725
+ ((message[0] == '"' and message[-1] == '"') or \
1726
+ (message[0] == "'" and message[-1] == "'")):
1727
+ message = message[1:-1]
1728
+ # If it ends with a quote but doesn't start with one,
1729
+ # it's likely from echo "HCOM_SEND:message" format
1730
+ elif message and message[-1] in '"\'':
1731
+ message = message[:-1]
1732
+
1733
+ if message and not instance_name.endswith("claude"):
1734
+ # Validate message
1735
+ error = validate_message(message)
1736
+ if error:
1737
+ output = {"reason": f"❌ {error}"}
1738
+ print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1739
+ sys.exit(EXIT_BLOCK)
1740
+
1741
+ send_message(instance_name, message)
1742
+
1743
+ # Check for pending messages to deliver
1744
+ if not instance_name.endswith("claude"):
1745
+ messages = get_new_messages(instance_name)
1746
+
1747
+ if messages:
1748
+ # Deliver messages via exit code 2
1749
+ max_messages = get_config_value('max_messages_per_delivery', 20)
1750
+ messages_to_show = messages[:max_messages]
1751
+
1752
+ output = {
1753
+ "decision": HOOK_DECISION_BLOCK,
1754
+ "reason": f"New messages from hcom:\n" +
1755
+ "\n".join([f"{m['from']}: {m['message']}" for m in messages_to_show])
1756
+ }
1757
+ print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1758
+ sys.exit(EXIT_BLOCK)
1759
+
1760
+ except Exception:
1761
+ pass
1762
+
1763
+ sys.exit(EXIT_SUCCESS)
1764
+
1765
+ def handle_hook_stop():
1766
+ """Handle Stop hook"""
1767
+ # Check if active
1768
+ if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
1769
+ sys.exit(EXIT_SUCCESS)
1770
+
1771
+ try:
1772
+ # Read hook input
1773
+ hook_data = json.load(sys.stdin)
1774
+ transcript_path = hook_data.get('transcript_path', '')
1775
+ instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
1776
+ conversation_uuid = get_conversation_uuid(transcript_path)
1777
+
1778
+ if instance_name.endswith("claude") and not conversation_uuid:
1779
+ sys.exit(EXIT_SUCCESS)
1780
+
1781
+ # Initialize instance if needed
1782
+ initialize_instance_in_position_file(instance_name, conversation_uuid)
1783
+
1784
+ # Update instance as waiting
1785
+ update_instance_position(instance_name, {
1786
+ 'last_stop': int(time.time()),
1787
+ 'session_id': hook_data.get('session_id', ''),
1788
+ 'transcript_path': transcript_path,
1789
+ 'conversation_uuid': conversation_uuid or 'unknown',
1790
+ 'directory': str(Path.cwd())
1791
+ })
1792
+
1793
+ parent_pid = os.getppid()
1794
+
1795
+ # Check for first-use help
1796
+ check_and_show_first_use_help(instance_name)
1797
+
1798
+ # Simple polling loop with parent check
1799
+ timeout = get_config_value('wait_timeout', 600)
1800
+ start_time = time.time()
1801
+
1802
+ while time.time() - start_time < timeout:
1803
+ # Check if parent is alive
1804
+ if not is_parent_alive(parent_pid):
1805
+ sys.exit(EXIT_SUCCESS)
1806
+
1807
+ # Check for new messages
1808
+ messages = get_new_messages(instance_name)
1809
+
1810
+ if messages:
1811
+ # Deliver messages
1812
+ max_messages = get_config_value('max_messages_per_delivery', 20)
1813
+ messages_to_show = messages[:max_messages]
1814
+
1815
+ output = {
1816
+ "decision": HOOK_DECISION_BLOCK,
1817
+ "reason": f"New messages from hcom:\n" +
1818
+ "\n".join([f"{m['from']}: {m['message']}" for m in messages_to_show])
1819
+ }
1820
+ print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1821
+ sys.exit(EXIT_BLOCK)
1822
+
1823
+ # Update heartbeat
1824
+ update_instance_position(instance_name, {
1825
+ 'last_stop': int(time.time())
1826
+ })
1827
+
1828
+ time.sleep(1)
1829
+
1830
+ except Exception:
1831
+ pass
1832
+
1833
+ sys.exit(EXIT_SUCCESS)
1834
+
1835
+ def handle_hook_notification():
1836
+ """Handle Notification hook"""
1837
+ # Check if active
1838
+ if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
1839
+ sys.exit(EXIT_SUCCESS)
1840
+
1841
+ try:
1842
+ # Read hook input
1843
+ hook_data = json.load(sys.stdin)
1844
+ transcript_path = hook_data.get('transcript_path', '')
1845
+ instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
1846
+ conversation_uuid = get_conversation_uuid(transcript_path)
1847
+
1848
+ if instance_name.endswith("claude") and not conversation_uuid:
1849
+ sys.exit(EXIT_SUCCESS)
1850
+
1851
+ # Initialize instance if needed
1852
+ initialize_instance_in_position_file(instance_name, conversation_uuid)
1853
+
1854
+ # Update permission request timestamp
1855
+ update_instance_position(instance_name, {
1856
+ 'last_permission_request': int(time.time()),
1857
+ 'notification_message': hook_data.get('message', ''),
1858
+ 'session_id': hook_data.get('session_id', ''),
1859
+ 'transcript_path': transcript_path,
1860
+ 'conversation_uuid': conversation_uuid or 'unknown',
1861
+ 'directory': str(Path.cwd())
1862
+ })
1863
+
1864
+ check_and_show_first_use_help(instance_name)
1865
+
1866
+ except Exception:
1867
+ pass
1868
+
1869
+ sys.exit(EXIT_SUCCESS)
1870
+
1871
+ # ==================== Main Entry Point ====================
1872
+
1873
+ def main(argv=None):
1874
+ """Main command dispatcher"""
1875
+ if argv is None:
1876
+ argv = sys.argv
1877
+
1878
+ if len(argv) < 2:
1879
+ return cmd_help()
1880
+
1881
+ cmd = argv[1]
1882
+
1883
+ # Main commands
1884
+ if cmd == 'help' or cmd == '--help':
1885
+ return cmd_help()
1886
+ elif cmd == 'open':
1887
+ return cmd_open(*argv[2:])
1888
+ elif cmd == 'watch':
1889
+ return cmd_watch(*argv[2:])
1890
+ elif cmd == 'clear':
1891
+ return cmd_clear()
1892
+ elif cmd == 'cleanup':
1893
+ return cmd_cleanup(*argv[2:])
1894
+ elif cmd == 'send':
1895
+ if len(argv) < 3:
1896
+ print(format_error("Message required"), file=sys.stderr)
1897
+ return 1
1898
+ return cmd_send(argv[2])
1899
+
1900
+ # Hook commands
1901
+ elif cmd == 'post':
1902
+ handle_hook_post()
1903
+ return 0
1904
+ elif cmd == 'stop':
1905
+ handle_hook_stop()
1906
+ return 0
1907
+ elif cmd == 'notify':
1908
+ handle_hook_notification()
1909
+ return 0
1910
+
1911
+ # Unknown command
1912
+ else:
1913
+ print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
1914
+ return 1
1915
+
1916
+ if __name__ == '__main__':
1917
+ sys.exit(main())