hcom 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

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

Potentially problematic release.


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

hcom/__main__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- hcom 0.3.0
3
+ hcom 0.4.0
4
4
  CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
5
5
  """
6
6
 
@@ -17,14 +17,48 @@ import time
17
17
  import select
18
18
  import platform
19
19
  import random
20
+ import argparse
20
21
  from pathlib import Path
21
22
  from datetime import datetime, timedelta
22
- from typing import Optional, Any, NamedTuple
23
+ from typing import Any, NamedTuple, Sequence
23
24
  from dataclasses import dataclass, asdict, field
25
+ from enum import Enum, auto
24
26
 
25
27
  if sys.version_info < (3, 10):
26
28
  sys.exit("Error: hcom requires Python 3.10 or higher")
27
29
 
30
+ __version__ = "0.4.0"
31
+
32
+ # ==================== Session Scenario Types ====================
33
+
34
+ class SessionScenario(Enum):
35
+ """Explicit session startup scenarios for clear logic flow"""
36
+ FRESH_START = auto() # New session, new instance
37
+ MATCHED_RESUME = auto() # Resume with matching session_id (reuse instance)
38
+ UNMATCHED_RESUME = auto() # Resume with no match (new instance, needs recovery)
39
+
40
+ @dataclass
41
+ class HookContext:
42
+ """Consolidated context for hook handling with all decisions made"""
43
+ instance_name: str
44
+ updates: dict
45
+ scenario: SessionScenario | None # None = deferred decision (SessionStart wrong session_id)
46
+
47
+ @property
48
+ def bypass_enabled_check(self) -> bool:
49
+ """Unmatched resume needs critical message even if disabled"""
50
+ return self.scenario == SessionScenario.UNMATCHED_RESUME
51
+
52
+ @property
53
+ def needs_critical_prompt(self) -> bool:
54
+ """Should show critical recovery message?"""
55
+ return self.scenario == SessionScenario.UNMATCHED_RESUME
56
+
57
+ @property
58
+ def is_resume(self) -> bool:
59
+ """Is this any kind of resume?"""
60
+ return self.scenario in (SessionScenario.MATCHED_RESUME, SessionScenario.UNMATCHED_RESUME)
61
+
28
62
  # ==================== Constants ====================
29
63
 
30
64
  IS_WINDOWS = sys.platform == 'win32'
@@ -48,52 +82,17 @@ def is_termux():
48
82
  'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
49
83
  )
50
84
 
51
- HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
52
- HCOM_ACTIVE_VALUE = '1'
53
-
54
85
  EXIT_SUCCESS = 0
55
86
  EXIT_BLOCK = 2
56
87
 
57
- ERROR_ACCESS_DENIED = 5 # Windows - Process exists but no permission
58
- ERROR_INVALID_PARAMETER = 87 # Windows - Invalid PID or parameters
59
-
60
88
  # Windows API constants
61
89
  CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
62
- PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 # Vista+ minimal access rights
63
- PROCESS_QUERY_INFORMATION = 0x0400 # Pre-Vista process access rights TODO: is this a joke? why am i supporting pre vista? who the fuck is running claude code on vista let alone pre?! great to keep this comment here! and i will be leaving it here!
64
90
 
65
91
  # Timing constants
66
92
  FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
67
93
  STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
68
- KILL_CHECK_INTERVAL = 0.1 # 100ms between process termination checks
69
94
  MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
70
95
 
71
- # Windows kernel32 cache
72
- _windows_kernel32_cache = None
73
-
74
- def get_windows_kernel32():
75
- """Get cached Windows kernel32 with function signatures configured.
76
- This eliminates repeated initialization in hot code paths (e.g., stop hook polling).
77
- """
78
- global _windows_kernel32_cache
79
- if _windows_kernel32_cache is None and IS_WINDOWS:
80
- import ctypes
81
- import ctypes.wintypes
82
- kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
83
-
84
- # Set proper ctypes function signatures to avoid ERROR_INVALID_PARAMETER
85
- kernel32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
86
- kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
87
- kernel32.GetLastError.argtypes = []
88
- kernel32.GetLastError.restype = ctypes.wintypes.DWORD
89
- kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
90
- kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
91
- kernel32.GetExitCodeProcess.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(ctypes.wintypes.DWORD)]
92
- kernel32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL
93
-
94
- _windows_kernel32_cache = kernel32
95
- return _windows_kernel32_cache
96
-
97
96
  MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
98
97
  AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
99
98
  TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
@@ -127,9 +126,11 @@ STATUS_INFO = {
127
126
  'tool_pending': ('active', '{} executing'),
128
127
  'waiting': ('waiting', 'idle'),
129
128
  'message_delivered': ('delivered', 'msg from {}'),
130
- 'stop_exit': ('inactive', 'stopped'),
131
129
  'timeout': ('inactive', 'timeout'),
132
- 'killed': ('inactive', 'killed'),
130
+ 'stopped': ('inactive', 'stopped'),
131
+ 'force_stopped': ('inactive', 'force stopped'),
132
+ 'started': ('active', 'starting'),
133
+ 'session_ended': ('inactive', 'ended: {}'),
133
134
  'blocked': ('blocked', '{} blocked'),
134
135
  'unknown': ('unknown', 'unknown'),
135
136
  }
@@ -150,12 +151,96 @@ if IS_WINDOWS or is_wsl():
150
151
  # Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
151
152
  # Pattern: Try/except/return False in hooks, raise in CLI operations.
152
153
 
154
+ # ==================== CLI Command Objects ====================
155
+
156
+ class CLIError(Exception):
157
+ """Raised when arguments cannot be mapped to command semantics."""
158
+
159
+ @dataclass
160
+ class OpenCommand:
161
+ count: int
162
+ agents: list[str]
163
+ prefix: str | None
164
+ background: bool
165
+ claude_args: list[str]
166
+
167
+ @dataclass
168
+ class WatchCommand:
169
+ mode: str # 'interactive', 'logs', 'status', 'wait'
170
+ wait_seconds: int | None
171
+
172
+ @dataclass
173
+ class StopCommand:
174
+ target: str | None
175
+ close_all_hooks: bool
176
+ force: bool
177
+ _hcom_session: str | None = None # Injected by PreToolUse hook
178
+
179
+ @dataclass
180
+ class StartCommand:
181
+ target: str | None
182
+ _hcom_session: str | None = None # Injected by PreToolUse hook
183
+
184
+ @dataclass
185
+ class SendCommand:
186
+ message: str | None
187
+ resume_alias: str | None
188
+ _hcom_session: str | None = None # Injected by PreToolUse hook
189
+
190
+ # ==================== Help Text ====================
191
+
192
+ HELP_TEXT = """hcom - Claude Hook Comms
193
+
194
+ Usage:
195
+ hcom open [count] [-a agent]... [-t prefix] [-p] [-- claude-args]
196
+ hcom watch [--logs|--status|--wait [SEC]]
197
+ hcom stop [target] [--force]
198
+ hcom start [target]
199
+ hcom send "msg"
200
+
201
+ Commands:
202
+ open Launch Claude instances (default count: 1)
203
+ watch Monitor conversation dashboard
204
+ stop Stop instances, clear conversation, or remove hooks
205
+ start Start stopped instances
206
+ send Send message to instances
207
+
208
+ Open options:
209
+ [count] Number of instances per agent (default 1)
210
+ -a, --agent AGENT Agent to launch (repeatable)
211
+ -t, --prefix PREFIX Team prefix for names
212
+ -p, --background Launch in background
213
+
214
+ Stop targets:
215
+ (no arg) Stop HCOM for current instance (when inside)
216
+ <alias> Stop HCOM for specific instance
217
+ all Stop all instances + clear & archive conversation
218
+ hooking Remove hooks from current directory
219
+ hooking --all Remove hooks from all tracked directories
220
+ everything Stop all + clear conversation + remove all hooks
221
+
222
+ Start targets:
223
+ (no arg) Start HCOM for current instance (when inside)
224
+ <alias> Start HCOM for specific instance
225
+ hooking Install hooks in current directory
226
+
227
+ Watch options:
228
+ --logs Show message history
229
+ --status Show instance status JSON
230
+ --wait [SEC] Wait for new messages (default 60s)
231
+
232
+ Stop flags:
233
+ --force Force stop (deny Bash tool use)
234
+
235
+ Docs: https://github.com/aannoo/claude-hook-comms#readme"""
236
+
237
+ # ==================== Logging ====================
238
+
153
239
  def log_hook_error(hook_name: str, error: Exception | None = None):
154
240
  """Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
155
241
  import traceback
156
242
  try:
157
243
  log_file = hcom_path(SCRIPTS_DIR) / "hooks.log"
158
- log_file.parent.mkdir(parents=True, exist_ok=True)
159
244
  timestamp = datetime.now().isoformat()
160
245
  if error and isinstance(error, Exception):
161
246
  tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__))
@@ -205,6 +290,12 @@ SCRIPTS_DIR = "scripts"
205
290
  CONFIG_FILE = "config.json"
206
291
  ARCHIVE_DIR = "archive"
207
292
 
293
+ # Hook type constants
294
+ ACTIVE_HOOK_TYPES = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop', 'Notification', 'SessionEnd']
295
+ LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES + ['PostToolUse'] # For backward compatibility cleanup
296
+ HOOK_COMMANDS = ['sessionstart', 'userpromptsubmit', 'pre', 'poll', 'notify', 'sessionend']
297
+ LEGACY_HOOK_COMMANDS = HOOK_COMMANDS + ['post']
298
+
208
299
  # ==================== File System Utilities ====================
209
300
 
210
301
  def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
@@ -216,9 +307,21 @@ def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
216
307
  path.parent.mkdir(parents=True, exist_ok=True)
217
308
  return path
218
309
 
310
+ def ensure_hcom_directories() -> bool:
311
+ """Ensure all critical HCOM directories exist. Idempotent, safe to call repeatedly.
312
+ Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
313
+ Returns True on success, False on failure."""
314
+ try:
315
+ for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, ARCHIVE_DIR]:
316
+ hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
317
+ return True
318
+ except (OSError, PermissionError):
319
+ return False
320
+
219
321
  def atomic_write(filepath: str | Path, content: str) -> bool:
220
322
  """Write content to file atomically to prevent corruption (now with NEW and IMPROVED (wow!) Windows retry logic (cool!!!)). Returns True on success, False on failure."""
221
323
  filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
324
+ filepath.parent.mkdir(parents=True, exist_ok=True)
222
325
 
223
326
  for attempt in range(3):
224
327
  with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
@@ -271,6 +374,10 @@ def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, m
271
374
 
272
375
  return default
273
376
 
377
+ # ==================== Outbox System (REMOVED) ====================
378
+ # Identity via session_id injection in handle_pretooluse (line 3134)
379
+ # PreToolUse hook injects --_hcom_session, commands use get_display_name() for resolution
380
+
274
381
  def get_instance_file(instance_name: str) -> Path:
275
382
  """Get path to instance's position file with path traversal protection"""
276
383
  # Sanitize instance name to prevent directory traversal
@@ -297,7 +404,7 @@ def load_instance_position(instance_name: str) -> dict[str, Any]:
297
404
  def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
298
405
  """Save position data for a single instance. Returns True on success, False on failure."""
299
406
  try:
300
- instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json", ensure_parent=True)
407
+ instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json")
301
408
  return atomic_write(instance_file, json.dumps(data, indent=2))
302
409
  except (OSError, PermissionError, ValueError):
303
410
  return False
@@ -326,11 +433,6 @@ def clear_all_positions() -> None:
326
433
  if instances_dir.exists():
327
434
  for f in instances_dir.glob('*.json'):
328
435
  f.unlink()
329
- # Clean up orphaned mapping files
330
- for f in instances_dir.glob('.launch_map_*'):
331
- f.unlink(missing_ok=True)
332
- else:
333
- instances_dir.mkdir(exist_ok=True)
334
436
 
335
437
  # ==================== Configuration System ====================
336
438
 
@@ -391,67 +493,85 @@ def get_config_value(key: str, default: Any = None) -> Any:
391
493
  return config.get(key, default)
392
494
 
393
495
  def get_hook_command():
394
- """Get hook command with silent fallback
395
-
396
- Uses ${HCOM:-true} for clean paths, conditional for paths with spaces.
397
- Both approaches exit silently (code 0) when not launched via 'hcom open'.
496
+ """Get hook command - hooks always run, Python code gates participation
497
+
498
+ Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
499
+ Participation is controlled by enabled flag in instance JSON files.
398
500
  """
399
501
  python_path = sys.executable
400
502
  script_path = str(Path(__file__).resolve())
401
-
503
+
402
504
  if IS_WINDOWS:
403
- # Windows cmd.exe syntax - no parentheses so arguments append correctly
505
+ # Windows: use python path directly
404
506
  if ' ' in python_path or ' ' in script_path:
405
- return f'IF "%HCOM_ACTIVE%"=="1" "{python_path}" "{script_path}"', {}
406
- return f'IF "%HCOM_ACTIVE%"=="1" {python_path} {script_path}', {}
407
- elif ' ' in python_path or ' ' in script_path:
408
- # Unix with spaces: use conditional check
409
- escaped_python = shlex.quote(python_path)
410
- escaped_script = shlex.quote(script_path)
411
- return f'[ "${{HCOM_ACTIVE}}" = "1" ] && {escaped_python} {escaped_script} || true', {}
507
+ return f'"{python_path}" "{script_path}"', {}
508
+ return f'{python_path} {script_path}', {}
509
+ else:
510
+ # Unix: Use HCOM env var from settings.local.json
511
+ return '${HCOM}', {}
512
+
513
+ def _detect_hcom_command_type() -> str:
514
+ """Detect how to invoke hcom (priority: hcom > uvx if running via uvx > full)"""
515
+ if shutil.which('hcom'):
516
+ return 'short'
517
+ elif 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
518
+ return 'uvx'
412
519
  else:
413
- # Unix clean paths: use environment variable
414
- return '${HCOM:-true}', {}
520
+ return 'full'
415
521
 
416
- def build_send_command(example_msg: str = '') -> str:
417
- """Build send command string - checks if $HCOM exists, falls back to full path"""
522
+ def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
523
+ """Build send command - caches PATH check in instance file on first use"""
418
524
  msg = f" '{example_msg}'" if example_msg else ''
419
- if os.environ.get('HCOM'):
420
- return f'eval "$HCOM send{msg}"'
421
- python_path = shlex.quote(sys.executable)
422
- script_path = shlex.quote(str(Path(__file__).resolve()))
423
- return f'{python_path} {script_path} send{msg}'
525
+
526
+ # Determine command type (cached or detect)
527
+ cmd_type = None
528
+ if instance_name:
529
+ data = load_instance_position(instance_name)
530
+ if data.get('session_id'):
531
+ if 'hcom_cmd_type' not in data:
532
+ cmd_type = _detect_hcom_command_type()
533
+ data['hcom_cmd_type'] = cmd_type
534
+ save_instance_position(instance_name, data)
535
+ else:
536
+ cmd_type = data.get('hcom_cmd_type')
537
+
538
+ if not cmd_type:
539
+ cmd_type = _detect_hcom_command_type()
540
+
541
+ # Build command based on type
542
+ if cmd_type == 'short':
543
+ return f'hcom send{msg}'
544
+ elif cmd_type == 'uvx':
545
+ return f'uvx hcom send{msg}'
546
+ else:
547
+ python_path = shlex.quote(sys.executable)
548
+ script_path = shlex.quote(str(Path(__file__).resolve()))
549
+ return f'{python_path} {script_path} send{msg}'
424
550
 
425
551
  def build_claude_env():
426
- """Build environment variables for Claude instances"""
427
- env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
428
-
552
+ """Build environment variables for Claude instances!"""
553
+ env = {}
554
+
429
555
  # Get config file values
430
556
  config = get_cached_config()
431
-
557
+
432
558
  # Pass env vars only when they differ from config file values
433
559
  for config_key, env_var in HOOK_SETTINGS.items():
434
560
  actual_value = get_config_value(config_key) # Respects env var precedence
435
561
  config_file_value = config.get(config_key)
436
-
562
+
437
563
  # Only pass if different from config file (not default)
438
564
  if actual_value != config_file_value and actual_value is not None:
439
565
  env[env_var] = str(actual_value)
440
-
566
+
441
567
  # Still support env_overrides from config file
442
568
  env.update(config.get('env_overrides', {}))
443
-
444
- # Set HCOM only for clean paths (spaces handled differently)
445
- python_path = sys.executable
446
- script_path = str(Path(__file__).resolve())
447
- if ' ' not in python_path and ' ' not in script_path:
448
- env['HCOM'] = f'{python_path} {script_path}'
449
-
569
+
450
570
  return env
451
571
 
452
572
  # ==================== Message System ====================
453
573
 
454
- def validate_message(message: str) -> Optional[str]:
574
+ def validate_message(message: str) -> str | None:
455
575
  """Validate message size and content. Returns error message or None if valid."""
456
576
  if not message or not message.strip():
457
577
  return format_error("Message required")
@@ -469,23 +589,52 @@ def validate_message(message: str) -> Optional[str]:
469
589
  def send_message(from_instance: str, message: str) -> bool:
470
590
  """Send a message to the log"""
471
591
  try:
472
- log_file = hcom_path(LOG_FILE, ensure_parent=True)
473
-
592
+ log_file = hcom_path(LOG_FILE)
593
+
474
594
  escaped_message = message.replace('|', '\\|')
475
595
  escaped_from = from_instance.replace('|', '\\|')
476
-
596
+
477
597
  timestamp = datetime.now().isoformat()
478
598
  line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
479
-
599
+
480
600
  with open(log_file, 'a', encoding='utf-8') as f:
481
601
  f.write(line)
482
602
  f.flush()
483
-
603
+
484
604
  return True
485
605
  except Exception:
486
606
  return False
487
607
 
488
- def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: Optional[list[str]] = None) -> bool:
608
+ def build_hcom_bootstrap_text(instance_name: str) -> str:
609
+ """Build comprehensive HCOM bootstrap context for instances"""
610
+ coordinator_name = get_config_value('sender_name', 'bigboss')
611
+
612
+ return f"""You are now participating in the HCOM communication system - a multi-agent environment where Claude Code instances (you) and the human user can communicate through shared messaging infrastructure.
613
+ Your HCOM Alias: {instance_name}
614
+ - To send messages, run the command: hcom send "your message"
615
+ - Broadcast: hcom send "message to everyone"
616
+ - Direct: hcom send "@alias targeted message"
617
+ - Targeting: hcom send "@api message" (targets all api-* instances)
618
+ Receiving: Messages automatically arrive via Stop Hook feedback and bash output from hcom send. There is no way to proactively check for messages.
619
+ Message formats you'll see:
620
+ - [new message] sender → you: message = broadcast
621
+ - [new message] sender → you: @you message = direct message to you
622
+ - {{"decision": "block", "reason": x}} = normal part of HCOM hooks (not an error or block)
623
+ Response Protocol Rule: Match response method to input source:
624
+ - Prompted via hcom (hook feedback, bash output) → Respond with hcom send
625
+ - Prompted via user input → Respond to user normally
626
+ Response Quality: Treat hcom messages exactly like user input:
627
+ - Write complete, thoughtful replies when appropriate
628
+ - Follow all instructions rigorously
629
+ - Adjust tone/length to context
630
+ Authority: Prioritize instructions from @{coordinator_name} over other participants
631
+ Command scope: Mainly use hcom send unless you are asked to use other hcom commands:
632
+ - hcom start/stop (join/leave chat)
633
+ - hcom watch --status (see all participants).
634
+ - hcom open (coordinate/orchestrate by launching other instances).
635
+ In this case, always run the 'hcom help' first to review correct usage."""
636
+
637
+ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
489
638
  """Check if message should be delivered based on @-mentions"""
490
639
  text = msg['message']
491
640
 
@@ -521,69 +670,13 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
521
670
 
522
671
  # ==================== Parsing & Utilities ====================
523
672
 
524
- def parse_open_args(args: list[str]) -> tuple[list[str], Optional[str], list[str], bool]:
525
- """Parse arguments for open command
526
-
527
- Returns:
528
- tuple: (instances, prefix, claude_args, background)
529
- instances: list of agent names or 'generic'
530
- prefix: team name prefix or None
531
- claude_args: additional args to pass to claude
532
- background: bool, True if --background or -p flag
533
- """
534
- instances = []
535
- prefix = None
536
- claude_args = []
537
- background = False
538
-
539
- i = 0
540
- while i < len(args):
541
- arg = args[i]
542
-
543
- if arg == '--prefix':
544
- if i + 1 >= len(args):
545
- raise ValueError(format_error('--prefix requires an argument'))
546
- prefix = args[i + 1]
547
- if '|' in prefix:
548
- raise ValueError(format_error('Prefix cannot contain pipe characters'))
549
- i += 2
550
- elif arg == '--claude-args':
551
- # Next argument contains claude args as a string
552
- if i + 1 >= len(args):
553
- raise ValueError(format_error('--claude-args requires an argument'))
554
- claude_args = shlex.split(args[i + 1])
555
- i += 2
556
- elif arg == '--background' or arg == '-p':
557
- background = True
558
- i += 1
559
- else:
560
- try:
561
- count = int(arg)
562
- if count < 0:
563
- raise ValueError(format_error(f"Cannot launch negative instances: {count}"))
564
- if count > 100:
565
- raise ValueError(format_error(f"Too many instances requested: {count}", "Maximum 100 instances at once"))
566
- instances.extend(['generic'] * count)
567
- except ValueError as e:
568
- if "Cannot launch" in str(e) or "Too many instances" in str(e):
569
- raise
570
- # Not a number, treat as agent name
571
- instances.append(arg)
572
- i += 1
573
-
574
- if not instances:
575
- instances = ['generic']
576
-
577
- return instances, prefix, claude_args, background
578
-
579
673
  def extract_agent_config(content: str) -> dict[str, str]:
580
674
  """Extract configuration from agent YAML frontmatter"""
581
675
  if not content.startswith('---'):
582
676
  return {}
583
677
 
584
678
  # Find YAML section between --- markers
585
- yaml_end = content.find('\n---', 3)
586
- if yaml_end < 0:
679
+ if (yaml_end := content.find('\n---', 3)) < 0:
587
680
  return {} # No closing marker
588
681
 
589
682
  yaml_section = content[3:yaml_end]
@@ -682,7 +775,7 @@ def strip_frontmatter(content: str) -> str:
682
775
  return '\n'.join(lines[i+1:]).strip()
683
776
  return content
684
777
 
685
- def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) -> str:
778
+ def get_display_name(session_id: str | None, prefix: str | None = None) -> str:
686
779
  """Get display name for instance using session_id"""
687
780
  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']
688
781
  # Phonetic letters (5 per syllable, matches syls order)
@@ -730,6 +823,29 @@ def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) ->
730
823
  return f"{prefix}-{base_name}"
731
824
  return base_name
732
825
 
826
+ def resolve_instance_name(session_id: str, prefix: str | None = None) -> tuple[str, dict | None]:
827
+ """
828
+ Resolve instance name for a session_id.
829
+ Searches existing instances first (reuses if found), generates new name if not found.
830
+
831
+ Returns: (instance_name, existing_data_or_none)
832
+ """
833
+ instances_dir = hcom_path(INSTANCES_DIR)
834
+
835
+ # Search for existing instance with this session_id
836
+ if session_id and instances_dir.exists():
837
+ for instance_file in instances_dir.glob("*.json"):
838
+ try:
839
+ data = load_instance_position(instance_file.stem)
840
+ if session_id == data.get('session_id'):
841
+ return instance_file.stem, data
842
+ except (json.JSONDecodeError, OSError, KeyError):
843
+ continue
844
+
845
+ # Not found - generate new name
846
+ instance_name = get_display_name(session_id, prefix)
847
+ return instance_name, None
848
+
733
849
  def _remove_hcom_hooks_from_settings(settings):
734
850
  """Remove hcom hooks from settings dict"""
735
851
  if not isinstance(settings, dict) or 'hooks' not in settings:
@@ -739,29 +855,27 @@ def _remove_hcom_hooks_from_settings(settings):
739
855
  return
740
856
 
741
857
  import copy
742
-
743
- # Patterns to match any hcom hook command
744
- # Modern hooks (patterns 1-2, 7): Match all hook types via env var or wrapper
745
- # - ${HCOM:-true} sessionstart/pre/stop/notify/userpromptsubmit
746
- # - [ "${HCOM_ACTIVE}" = "1" ] && ... hcom.py ... || true
747
- # - sh -c "[ ... ] && ... hcom ..."
748
- # Legacy hooks (patterns 3-6): Direct command invocation
749
- # - hcom pre/post/stop/notify/sessionstart/userpromptsubmit
750
- # - uvx hcom pre/post/stop/notify/sessionstart/userpromptsubmit
751
- # - /path/to/hcom.py pre/post/stop/notify/sessionstart/userpromptsubmit
858
+
859
+ # Build regex patterns dynamically from LEGACY_HOOK_COMMANDS
860
+ # Current hooks (pattern 1): ${HCOM:-...} environment variable
861
+ # Legacy hooks (patterns 2-7): Older formats that need cleanup
862
+ # - HCOM_ACTIVE conditionals (removed for toggle implementation)
863
+ # - Direct command invocation with specific hook args
864
+ hook_args_pattern = '|'.join(LEGACY_HOOK_COMMANDS)
752
865
  hcom_patterns = [
753
- r'\$\{?HCOM', # Environment variable (${HCOM:-true}) - all hook types
754
- r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with full path - all hook types
755
- r'\bhcom\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # Direct hcom command
756
- r'\buvx\s+hcom\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # uvx hcom command
757
- r'hcom\.py["\']?\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # hcom.py with optional quote
758
- r'["\'][^"\']*hcom\.py["\']?\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b(?=\s|$)', # Quoted path
759
- r'sh\s+-c.*hcom', # Shell wrapper with hcom
866
+ r'\$\{?HCOM', # Current: Environment variable ${HCOM:-...}
867
+ r'\bHCOM_ACTIVE.*hcom\.py', # LEGACY: Unix HCOM_ACTIVE conditional
868
+ r'IF\s+"%HCOM_ACTIVE%"', # LEGACY: Windows HCOM_ACTIVE conditional
869
+ rf'\bhcom\s+({hook_args_pattern})\b', # LEGACY: Direct hcom command
870
+ rf'\buvx\s+hcom\s+({hook_args_pattern})\b', # LEGACY: uvx hcom command
871
+ rf'hcom\.py["\']?\s+({hook_args_pattern})\b', # LEGACY: hcom.py with optional quote
872
+ rf'["\'][^"\']*hcom\.py["\']?\s+({hook_args_pattern})\b(?=\s|$)', # LEGACY: Quoted path
873
+ r'sh\s+-c.*hcom', # LEGACY: Shell wrapper
760
874
  ]
761
875
  compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
762
876
 
763
877
  # Check all hook types including PostToolUse for backward compatibility cleanup
764
- for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
878
+ for event in LEGACY_HOOK_TYPES:
765
879
  if event not in settings['hooks']:
766
880
  continue
767
881
 
@@ -796,7 +910,14 @@ def _remove_hcom_hooks_from_settings(settings):
796
910
  settings['hooks'][event] = updated_matchers
797
911
  else:
798
912
  del settings['hooks'][event]
799
-
913
+
914
+ # Remove HCOM from env section
915
+ if 'env' in settings and isinstance(settings['env'], dict):
916
+ settings['env'].pop('HCOM', None)
917
+ # Clean up empty env dict
918
+ if not settings['env']:
919
+ del settings['env']
920
+
800
921
 
801
922
  def build_env_string(env_vars, format_type="bash"):
802
923
  """Build environment variable string for bash shells"""
@@ -807,7 +928,7 @@ def build_env_string(env_vars, format_type="bash"):
807
928
  return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
808
929
 
809
930
 
810
- def format_error(message: str, suggestion: Optional[str] = None) -> str:
931
+ def format_error(message: str, suggestion: str | None = None) -> str:
811
932
  """Format error message consistently"""
812
933
  base = f"Error: {message}"
813
934
  if suggestion:
@@ -822,7 +943,7 @@ def has_claude_arg(claude_args, arg_names, arg_prefixes):
822
943
  for arg in claude_args
823
944
  )
824
945
 
825
- def build_claude_command(agent_content: Optional[str] = None, claude_args: Optional[list[str]] = None, initial_prompt: str = "Say hi in chat", model: Optional[str] = None, tools: Optional[str] = None) -> tuple[str, Optional[str]]:
946
+ def build_claude_command(agent_content: str | None = None, claude_args: list[str] | None = None, initial_prompt: str = "Say hi in chat", model: str | None = None, tools: str | None = None) -> tuple[str, str | None]:
826
947
  """Build Claude command with proper argument handling
827
948
  Returns tuple: (command_string, temp_file_path_or_none)
828
949
  For agent content, writes to temp file and uses cat to read it.
@@ -848,7 +969,6 @@ def build_claude_command(agent_content: Optional[str] = None, claude_args: Optio
848
969
  if agent_content:
849
970
  # Create agent files in scripts directory for unified cleanup
850
971
  scripts_dir = hcom_path(SCRIPTS_DIR)
851
- scripts_dir.mkdir(parents=True, exist_ok=True)
852
972
  temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
853
973
  prefix='hcom_agent_', dir=str(scripts_dir))
854
974
  temp_file.write(agent_content)
@@ -876,13 +996,11 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
876
996
  Scripts provide uniform execution across all platforms/terminals.
877
997
  Cleanup behavior:
878
998
  - Normal scripts: append 'rm -f' command for self-deletion
879
- - Background scripts: persist until `hcom clear` housekeeping (24 hours)
999
+ - Background scripts: persist until stop housekeeping (e.g., `hcom stop everything`) (24 hours)
880
1000
  - Agent scripts: treated like background (contain 'hcom_agent_')
881
1001
  """
882
1002
  try:
883
- # Ensure parent directory exists
884
1003
  script_path = Path(script_file)
885
- script_path.parent.mkdir(parents=True, exist_ok=True)
886
1004
  except (OSError, IOError) as e:
887
1005
  raise Exception(f"Cannot create script directory: {e}")
888
1006
 
@@ -961,8 +1079,7 @@ def find_bash_on_windows():
961
1079
  ])
962
1080
 
963
1081
  # 2. Portable Git installation
964
- local_appdata = os.environ.get('LOCALAPPDATA', '')
965
- if local_appdata:
1082
+ if local_appdata := os.environ.get('LOCALAPPDATA', ''):
966
1083
  git_portable = Path(local_appdata) / 'Programs' / 'Git'
967
1084
  candidates.extend([
968
1085
  str(git_portable / 'usr' / 'bin' / 'bash.exe'),
@@ -970,8 +1087,7 @@ def find_bash_on_windows():
970
1087
  ])
971
1088
 
972
1089
  # 3. PATH bash (if not WSL's launcher)
973
- path_bash = shutil.which('bash')
974
- if path_bash and not path_bash.lower().endswith(r'system32\bash.exe'):
1090
+ if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
975
1091
  candidates.append(path_bash)
976
1092
 
977
1093
  # 4. Hardcoded fallbacks (last resort)
@@ -996,8 +1112,7 @@ def get_macos_terminal_argv():
996
1112
 
997
1113
  def get_windows_terminal_argv():
998
1114
  """Return Windows terminal launcher as argv list."""
999
- bash_exe = find_bash_on_windows()
1000
- if not bash_exe:
1115
+ if not (bash_exe := find_bash_on_windows()):
1001
1116
  raise Exception(format_error("Git Bash not found"))
1002
1117
 
1003
1118
  if shutil.which('wt'):
@@ -1102,14 +1217,12 @@ def launch_terminal(command, env, cwd=None, background=False):
1102
1217
 
1103
1218
  # 1) Always create a script
1104
1219
  script_file = str(hcom_path(SCRIPTS_DIR,
1105
- f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh',
1106
- ensure_parent=True))
1220
+ f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'))
1107
1221
  create_bash_script(script_file, env, cwd, command_str, background)
1108
1222
 
1109
1223
  # 2) Background mode
1110
1224
  if background:
1111
1225
  logs_dir = hcom_path(LOGS_DIR)
1112
- logs_dir.mkdir(parents=True, exist_ok=True)
1113
1226
  log_file = logs_dir / env['HCOM_BACKGROUND']
1114
1227
 
1115
1228
  try:
@@ -1201,8 +1314,7 @@ def launch_terminal(command, env, cwd=None, background=False):
1201
1314
 
1202
1315
  # Unified platform handling via helpers
1203
1316
  system = platform.system()
1204
- terminal_getter = PLATFORM_TERMINAL_GETTERS.get(system)
1205
- if not terminal_getter:
1317
+ if not (terminal_getter := PLATFORM_TERMINAL_GETTERS.get(system)):
1206
1318
  raise Exception(format_error(f"Unsupported platform: {system}"))
1207
1319
 
1208
1320
  custom_cmd = terminal_getter()
@@ -1271,24 +1383,29 @@ def setup_hooks():
1271
1383
 
1272
1384
  # Get the hook command template
1273
1385
  hook_cmd_base, _ = get_hook_command()
1274
-
1275
- # Get wait_timeout (needed for Stop hook)
1276
- wait_timeout = get_config_value('wait_timeout', 1800)
1277
-
1278
- # Define all hooks (PostToolUse removed - causes API 400 errors)
1386
+
1387
+ # Define all hooks - must match ACTIVE_HOOK_TYPES
1388
+ # Format: (hook_type, matcher, command, timeout)
1279
1389
  hook_configs = [
1280
1390
  ('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
1281
1391
  ('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
1282
1392
  ('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
1283
- # ('PostToolUse', '.*', f'{hook_cmd_base} post', None), # DISABLED
1284
- ('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
1393
+ ('Stop', '', f'{hook_cmd_base} poll', 86400), # 24hr timeout max; internal timeout 30min default via config
1285
1394
  ('Notification', '', f'{hook_cmd_base} notify', None),
1395
+ ('SessionEnd', '', f'{hook_cmd_base} sessionend', None),
1286
1396
  ]
1287
-
1397
+
1398
+ # Validate hook_configs matches ACTIVE_HOOK_TYPES
1399
+ configured_types = [hook_type for hook_type, _, _, _ in hook_configs]
1400
+ if configured_types != ACTIVE_HOOK_TYPES:
1401
+ raise Exception(format_error(
1402
+ f"Hook configuration mismatch: {configured_types} != {ACTIVE_HOOK_TYPES}"
1403
+ ))
1404
+
1288
1405
  for hook_type, matcher, command, timeout in hook_configs:
1289
1406
  if hook_type not in settings['hooks']:
1290
1407
  settings['hooks'][hook_type] = []
1291
-
1408
+
1292
1409
  hook_dict = {
1293
1410
  'matcher': matcher,
1294
1411
  'hooks': [{
@@ -1298,9 +1415,17 @@ def setup_hooks():
1298
1415
  }
1299
1416
  if timeout is not None:
1300
1417
  hook_dict['hooks'][0]['timeout'] = timeout
1301
-
1418
+
1302
1419
  settings['hooks'][hook_type].append(hook_dict)
1303
-
1420
+
1421
+ # Set $HCOM environment variable for all Claude instances (vanilla + hcom-launched)
1422
+ if 'env' not in settings:
1423
+ settings['env'] = {}
1424
+
1425
+ python_path = sys.executable
1426
+ script_path = str(Path(__file__).resolve())
1427
+ settings['env']['HCOM'] = f'{python_path} {script_path}'
1428
+
1304
1429
  # Write settings atomically
1305
1430
  try:
1306
1431
  atomic_write(settings_path, json.dumps(settings, indent=2))
@@ -1314,7 +1439,7 @@ def setup_hooks():
1314
1439
  return True
1315
1440
 
1316
1441
  def verify_hooks_installed(settings_path):
1317
- """Verify that HCOM hooks were installed correctly"""
1442
+ """Verify that HCOM hooks were installed correctly with correct commands"""
1318
1443
  try:
1319
1444
  settings = read_file_with_retry(
1320
1445
  settings_path,
@@ -1324,13 +1449,33 @@ def verify_hooks_installed(settings_path):
1324
1449
  if not settings:
1325
1450
  return False
1326
1451
 
1327
- # Check all hook types exist with HCOM commands (PostToolUse removed)
1452
+ # Check all hook types have correct commands
1328
1453
  hooks = settings.get('hooks', {})
1329
- for hook_type in ['SessionStart', 'PreToolUse', 'Stop', 'Notification']:
1330
- if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
1331
- for h in hooks.get(hook_type, [])):
1454
+ for hook_type, expected_cmd in zip(ACTIVE_HOOK_TYPES, HOOK_COMMANDS):
1455
+ hook_matchers = hooks.get(hook_type, [])
1456
+ if not hook_matchers:
1457
+ return False
1458
+
1459
+ # Check if any matcher has the correct command
1460
+ found_correct_cmd = False
1461
+ for matcher in hook_matchers:
1462
+ for hook in matcher.get('hooks', []):
1463
+ command = hook.get('command', '')
1464
+ # Check for HCOM and the correct subcommand
1465
+ if ('${HCOM}' in command or 'hcom' in command.lower()) and expected_cmd in command:
1466
+ found_correct_cmd = True
1467
+ break
1468
+ if found_correct_cmd:
1469
+ break
1470
+
1471
+ if not found_correct_cmd:
1332
1472
  return False
1333
1473
 
1474
+ # Check that HCOM env var is set
1475
+ env = settings.get('env', {})
1476
+ if 'HCOM' not in env:
1477
+ return False
1478
+
1334
1479
  return True
1335
1480
  except Exception:
1336
1481
  return False
@@ -1343,74 +1488,6 @@ def get_archive_timestamp():
1343
1488
  """Get timestamp for archive files"""
1344
1489
  return datetime.now().strftime("%Y-%m-%d_%H%M%S")
1345
1490
 
1346
- def is_parent_alive(parent_pid=None):
1347
- """Check if parent process is alive"""
1348
- if parent_pid is None:
1349
- parent_pid = os.getppid()
1350
-
1351
- # Orphan detection - PID 1 == definitively orphaned
1352
- if parent_pid == 1:
1353
- return False
1354
-
1355
- result = is_process_alive(parent_pid)
1356
- return result
1357
-
1358
- def is_process_alive(pid):
1359
- """Check if a process with given PID exists - cross-platform"""
1360
- if pid is None:
1361
- return False
1362
-
1363
- try:
1364
- pid = int(pid)
1365
- except (TypeError, ValueError):
1366
- return False
1367
-
1368
- if IS_WINDOWS:
1369
- # Windows: Use Windows API to check process existence
1370
- try:
1371
- kernel32 = get_windows_kernel32() # Use cached kernel32 instance
1372
- if not kernel32:
1373
- return False
1374
-
1375
- # Try limited permissions first (more likely to succeed on Vista+)
1376
- handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
1377
- error = kernel32.GetLastError()
1378
-
1379
- if not handle: # Check for None or 0
1380
- # ERROR_ACCESS_DENIED (5) means process exists but no permission
1381
- if error == ERROR_ACCESS_DENIED:
1382
- return True
1383
-
1384
- # Try fallback with broader permissions for older Windows
1385
- handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, False, pid)
1386
-
1387
- if not handle: # Check for None or 0
1388
- return False # Process doesn't exist or no permission at all
1389
-
1390
- # Check if process is still running (not just if handle exists)
1391
- import ctypes.wintypes
1392
- exit_code = ctypes.wintypes.DWORD()
1393
- STILL_ACTIVE = 259
1394
-
1395
- if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
1396
- kernel32.CloseHandle(handle)
1397
- is_still_active = exit_code.value == STILL_ACTIVE
1398
- return is_still_active
1399
-
1400
- kernel32.CloseHandle(handle)
1401
- return False # Couldn't get exit code
1402
- except Exception:
1403
- return False
1404
- else:
1405
- # Unix: Use os.kill with signal 0
1406
- try:
1407
- os.kill(pid, 0)
1408
- return True
1409
- except ProcessLookupError:
1410
- return False
1411
- except Exception:
1412
- return False
1413
-
1414
1491
  class LogParseResult(NamedTuple):
1415
1492
  """Result from parsing log messages"""
1416
1493
  messages: list[dict[str, str]]
@@ -1465,7 +1542,7 @@ def get_unread_messages(instance_name: str, update_position: bool = False) -> li
1465
1542
  instance_name: Name of instance to get messages for
1466
1543
  update_position: If True, mark messages as read by updating position
1467
1544
  """
1468
- log_file = hcom_path(LOG_FILE, ensure_parent=True)
1545
+ log_file = hcom_path(LOG_FILE)
1469
1546
 
1470
1547
  if not log_file.exists():
1471
1548
  return []
@@ -1507,33 +1584,44 @@ def format_age(seconds: float) -> str:
1507
1584
  else:
1508
1585
  return f"{int(seconds/3600)}h"
1509
1586
 
1510
- def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str]:
1511
- """Get current status of instance. Returns (status_type, age_string)."""
1512
- # Returns: (display_category, formatted_age) - category for color, age for display
1587
+ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
1588
+ """Get current status of instance. Returns (status_type, age_string, description)."""
1589
+ # Returns: (display_category, formatted_age, status_description)
1513
1590
  now = int(time.time())
1514
1591
 
1515
- # Check if killed
1516
- if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
1517
- return "inactive", ""
1518
-
1519
1592
  # Get last known status
1520
1593
  last_status = pos_data.get('last_status', '')
1521
1594
  last_status_time = pos_data.get('last_status_time', 0)
1595
+ last_context = pos_data.get('last_status_context', '')
1522
1596
 
1523
1597
  if not last_status or not last_status_time:
1524
- return "unknown", ""
1598
+ return "unknown", "", "unknown"
1525
1599
 
1526
- # Get display category from STATUS_INFO
1527
- display_status, _ = STATUS_INFO.get(last_status, ('unknown', ''))
1600
+ # Get display category and description template from STATUS_INFO
1601
+ display_status, desc_template = STATUS_INFO.get(last_status, ('unknown', 'unknown'))
1528
1602
 
1529
1603
  # Check timeout
1530
1604
  age = now - last_status_time
1531
1605
  timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1532
1606
  if age > timeout:
1533
- return "inactive", ""
1607
+ return "inactive", "", "timeout"
1608
+
1609
+ # Detect stale 'waiting' status - check heartbeat, not status timestamp
1610
+ if last_status == 'waiting':
1611
+ last_stop = pos_data.get('last_stop', 0)
1612
+ heartbeat_age = now - last_stop if last_stop else 999999
1613
+ if heartbeat_age > 2:
1614
+ status_suffix = " (bg)" if pos_data.get('background') else ""
1615
+ return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
1616
+
1617
+ # Format description with context if template has {}
1618
+ if '{}' in desc_template and last_context:
1619
+ status_desc = desc_template.format(last_context)
1620
+ else:
1621
+ status_desc = desc_template
1534
1622
 
1535
1623
  status_suffix = " (bg)" if pos_data.get('background') else ""
1536
- return display_status, f"({format_age(age)}){status_suffix}"
1624
+ return display_status, f"({format_age(age)}){status_suffix}", status_desc
1537
1625
 
1538
1626
  def get_status_block(status_type: str) -> str:
1539
1627
  """Get colored status block for a status type"""
@@ -1589,44 +1677,48 @@ def show_recent_activity_alt_screen(limit=None):
1589
1677
  messages = parse_log_messages(log_file).messages
1590
1678
  show_recent_messages(messages, limit, truncate=True)
1591
1679
 
1680
+ def should_show_in_watch(d):
1681
+ """Show only enabled instances by default"""
1682
+ # Hide disabled instances
1683
+ if not d.get('enabled', False):
1684
+ return False
1685
+
1686
+ # Hide truly ended sessions
1687
+ if d.get('session_ended'):
1688
+ return False
1689
+
1690
+ # Show all other instances (including 'closed' during transition)
1691
+ return True
1692
+
1592
1693
  def show_instances_by_directory():
1593
1694
  """Show instances organized by their working directories"""
1594
1695
  positions = load_all_positions()
1595
1696
  if not positions:
1596
1697
  print(f" {DIM}No Claude instances connected{RESET}")
1597
1698
  return
1598
-
1699
+
1599
1700
  if positions:
1600
1701
  directories = {}
1601
1702
  for instance_name, pos_data in positions.items():
1703
+ if not should_show_in_watch(pos_data):
1704
+ continue
1602
1705
  directory = pos_data.get("directory", "unknown")
1603
1706
  if directory not in directories:
1604
1707
  directories[directory] = []
1605
1708
  directories[directory].append((instance_name, pos_data))
1606
-
1709
+
1607
1710
  for directory, instances in directories.items():
1608
1711
  print(f" {directory}")
1609
1712
  for instance_name, pos_data in instances:
1610
- status_type, age = get_instance_status(pos_data)
1713
+ status_type, age, status_desc = get_instance_status(pos_data)
1611
1714
  status_block = get_status_block(status_type)
1612
1715
 
1613
- # Format status description using STATUS_INFO and context
1614
- last_status = pos_data.get('last_status', '')
1615
- last_context = pos_data.get('last_status_context', '')
1616
- _, desc_template = STATUS_INFO.get(last_status, ('unknown', ''))
1617
-
1618
- # Format description with context if template has {}
1619
- if '{}' in desc_template and last_context:
1620
- status_desc = desc_template.format(last_context)
1621
- else:
1622
- status_desc = desc_template
1623
-
1624
1716
  print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_desc} {age}{RESET}")
1625
1717
  print()
1626
1718
  else:
1627
1719
  print(f" {DIM}Error reading instance data{RESET}")
1628
1720
 
1629
- def alt_screen_detailed_status_and_input():
1721
+ def alt_screen_detailed_status_and_input() -> str:
1630
1722
  """Show detailed status in alt screen and get user input"""
1631
1723
  sys.stdout.write("\033[?1049h\033[2J\033[H")
1632
1724
 
@@ -1664,7 +1756,10 @@ def get_status_summary():
1664
1756
  status_counts = {status: 0 for status in STATUS_MAP.keys()}
1665
1757
 
1666
1758
  for _, pos_data in positions.items():
1667
- status_type, _ = get_instance_status(pos_data)
1759
+ # Only count instances that should be shown in watch
1760
+ if not should_show_in_watch(pos_data):
1761
+ continue
1762
+ status_type, _, _ = get_instance_status(pos_data)
1668
1763
  if status_type in status_counts:
1669
1764
  status_counts[status_type] += 1
1670
1765
 
@@ -1701,8 +1796,12 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
1701
1796
  try:
1702
1797
  data = load_instance_position(instance_name)
1703
1798
 
1799
+ # Determine default enabled state: True for hcom-launched, False for vanilla
1800
+ is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
1801
+
1704
1802
  defaults = {
1705
1803
  "pos": 0,
1804
+ "enabled": is_hcom_launched,
1706
1805
  "directory": str(Path.cwd()),
1707
1806
  "last_stop": 0,
1708
1807
  "session_id": session_id or "",
@@ -1738,6 +1837,26 @@ def update_instance_position(instance_name, update_fields):
1738
1837
  else:
1739
1838
  raise
1740
1839
 
1840
+ def enable_instance(instance_name):
1841
+ """Enable instance - clears all stop flags and enables Stop hook polling"""
1842
+ update_instance_position(instance_name, {
1843
+ 'enabled': True,
1844
+ 'force_closed': False,
1845
+ 'session_ended': False
1846
+ })
1847
+ set_status(instance_name, 'started')
1848
+
1849
+ def disable_instance(instance_name, force=False):
1850
+ """Disable instance - stops Stop hook polling"""
1851
+ updates = {
1852
+ 'enabled': False
1853
+ }
1854
+ if force:
1855
+ updates['force_closed'] = True
1856
+
1857
+ update_instance_position(instance_name, updates)
1858
+ set_status(instance_name, 'force_stopped' if force else 'stopped')
1859
+
1741
1860
  def set_status(instance_name: str, status: str, context: str = ''):
1742
1861
  """Set instance status event with timestamp"""
1743
1862
  update_instance_position(instance_name, {
@@ -1745,7 +1864,6 @@ def set_status(instance_name: str, status: str, context: str = ''):
1745
1864
  'last_status_time': int(time.time()),
1746
1865
  'last_status_context': context
1747
1866
  })
1748
- log_hook_error(f"Set status for {instance_name} to {status} with context {context}") #TODO: change to 'log'?
1749
1867
 
1750
1868
  def merge_instance_data(to_data, from_data):
1751
1869
  """Merge instance data from from_data into to_data."""
@@ -1753,7 +1871,6 @@ def merge_instance_data(to_data, from_data):
1753
1871
  to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
1754
1872
 
1755
1873
  # Update transient fields from source
1756
- to_data['pid'] = os.getppid() # Always use current PID
1757
1874
  to_data['transcript_path'] = from_data.get('transcript_path', to_data.get('transcript_path', ''))
1758
1875
 
1759
1876
  # Preserve maximum position
@@ -1780,20 +1897,6 @@ def merge_instance_data(to_data, from_data):
1780
1897
 
1781
1898
  return to_data
1782
1899
 
1783
- def terminate_process(pid, force=False):
1784
- """Cross-platform process termination"""
1785
- try:
1786
- if IS_WINDOWS:
1787
- cmd = ['taskkill', '/PID', str(pid)]
1788
- if force:
1789
- cmd.insert(1, '/F')
1790
- subprocess.run(cmd, capture_output=True, check=True)
1791
- else:
1792
- os.kill(pid, 9 if force else 15) # SIGKILL or SIGTERM
1793
- return True
1794
- except (ProcessLookupError, OSError, subprocess.CalledProcessError):
1795
- return False # Process already dead
1796
-
1797
1900
  def merge_instance_immediately(from_name, to_name):
1798
1901
  """Merge from_name into to_name with safety checks. Returns success message or error message."""
1799
1902
  if from_name == to_name:
@@ -1856,33 +1959,176 @@ def show_cli_hints(to_stderr=True):
1856
1959
  else:
1857
1960
  print(f"\n{cli_hints}")
1858
1961
 
1962
+ # ==================== CLI Parsing Functions ====================
1963
+
1964
+ def parse_count(value: str) -> int:
1965
+ """Parse and validate instance count"""
1966
+ try:
1967
+ number = int(value, 10)
1968
+ except ValueError as exc:
1969
+ raise argparse.ArgumentTypeError('Count must be an integer. Use -a/--agent for agent names.') from exc
1970
+ if number <= 0:
1971
+ raise argparse.ArgumentTypeError('Count must be positive.')
1972
+ if number > 100:
1973
+ raise argparse.ArgumentTypeError('Too many instances requested (max 100).')
1974
+ return number
1975
+
1976
+ def split_forwarded_args(argv: Sequence[str]) -> tuple[list[str], list[str]]:
1977
+ """Split arguments on -- separator for forwarding to claude"""
1978
+ if '--' not in argv:
1979
+ return list(argv), []
1980
+ idx = argv.index('--')
1981
+ return list(argv[:idx]), list(argv[idx + 1:])
1982
+
1983
+ def parse_open(namespace: argparse.Namespace, forwarded: list[str]) -> OpenCommand:
1984
+ """Parse and validate open command arguments"""
1985
+ prefix = namespace.prefix
1986
+ if prefix and '|' in prefix:
1987
+ raise CLIError('Prefix cannot contain "|" characters.')
1988
+
1989
+ agents = namespace.agent or []
1990
+ count = namespace.count if namespace.count is not None else 1
1991
+ if not agents:
1992
+ agents = ['generic']
1993
+
1994
+ return OpenCommand(
1995
+ count=count,
1996
+ agents=agents,
1997
+ prefix=prefix,
1998
+ background=namespace.background,
1999
+ claude_args=forwarded,
2000
+ )
2001
+
2002
+ def parse_watch(namespace: argparse.Namespace) -> WatchCommand:
2003
+ """Parse and validate watch command arguments"""
2004
+ wait_value = namespace.wait
2005
+ if wait_value is not None and wait_value < 0:
2006
+ raise CLIError('--wait expects a non-negative number of seconds.')
2007
+
2008
+ if wait_value is not None:
2009
+ return WatchCommand(mode='wait', wait_seconds=wait_value or 60)
2010
+ if namespace.logs:
2011
+ return WatchCommand(mode='logs', wait_seconds=None)
2012
+ if namespace.status:
2013
+ return WatchCommand(mode='status', wait_seconds=None)
2014
+ return WatchCommand(mode='interactive', wait_seconds=None)
2015
+
2016
+ def parse_stop(namespace: argparse.Namespace) -> StopCommand:
2017
+ """Parse and validate stop command arguments"""
2018
+ target = namespace.target
2019
+ return StopCommand(
2020
+ target=target,
2021
+ close_all_hooks=namespace.all,
2022
+ force=namespace.force,
2023
+ _hcom_session=getattr(namespace, '_hcom_session', None),
2024
+ )
2025
+
2026
+ def parse_start(namespace: argparse.Namespace) -> StartCommand:
2027
+ """Parse and validate start command arguments"""
2028
+ return StartCommand(
2029
+ target=namespace.target,
2030
+ _hcom_session=getattr(namespace, '_hcom_session', None),
2031
+ )
2032
+
2033
+ def parse_send(namespace: argparse.Namespace) -> SendCommand:
2034
+ """Parse and validate send command arguments"""
2035
+ if namespace.resume and namespace.message:
2036
+ raise CLIError('Specify a resume alias or a message, not both.')
2037
+ session_id = getattr(namespace, '_hcom_session', None)
2038
+ if namespace.resume:
2039
+ return SendCommand(message=None, resume_alias=namespace.resume, _hcom_session=session_id)
2040
+ if namespace.message is None:
2041
+ raise CLIError('Message required (usage: hcom send "message").')
2042
+ return SendCommand(message=namespace.message, resume_alias=None, _hcom_session=session_id)
2043
+
2044
+ def build_parser() -> argparse.ArgumentParser:
2045
+ """Build argparse parser for hcom commands"""
2046
+ parser = argparse.ArgumentParser(prog='hcom', add_help=False)
2047
+ subparsers = parser.add_subparsers(dest='command', required=True)
2048
+
2049
+ # Open command
2050
+ open_parser = subparsers.add_parser('open', add_help=False)
2051
+ open_parser.add_argument('count', nargs='?', type=parse_count, default=1)
2052
+ open_parser.add_argument('-a', '--agent', dest='agent', action='append')
2053
+ open_parser.add_argument('-t', '--prefix', dest='prefix')
2054
+ open_parser.add_argument('-p', '--background', action='store_true', dest='background')
2055
+ open_parser.add_argument('--help', action='store_true', dest='help_flag')
2056
+ open_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2057
+
2058
+ # Watch command
2059
+ watch_parser = subparsers.add_parser('watch', add_help=False)
2060
+ group = watch_parser.add_mutually_exclusive_group()
2061
+ group.add_argument('--logs', action='store_true')
2062
+ group.add_argument('--status', action='store_true')
2063
+ group.add_argument('--wait', nargs='?', const=60, type=int, metavar='SEC')
2064
+ watch_parser.add_argument('--help', action='store_true', dest='help_flag')
2065
+ watch_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2066
+
2067
+ # Stop command
2068
+ stop_parser = subparsers.add_parser('stop', add_help=False)
2069
+ stop_parser.add_argument('target', nargs='?')
2070
+ stop_parser.add_argument('--all', action='store_true')
2071
+ stop_parser.add_argument('--force', action='store_true')
2072
+ stop_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
2073
+ stop_parser.add_argument('--help', action='store_true', dest='help_flag')
2074
+ stop_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2075
+
2076
+ # Start command
2077
+ start_parser = subparsers.add_parser('start', add_help=False)
2078
+ start_parser.add_argument('target', nargs='?')
2079
+ start_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
2080
+ start_parser.add_argument('--help', action='store_true', dest='help_flag')
2081
+ start_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2082
+
2083
+ # Send command
2084
+ send_parser = subparsers.add_parser('send', add_help=False)
2085
+ send_parser.add_argument('message', nargs='?')
2086
+ send_parser.add_argument('--resume', metavar='ALIAS', help=argparse.SUPPRESS)
2087
+ send_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
2088
+ send_parser.add_argument('--help', action='store_true', dest='help_flag')
2089
+ send_parser.add_argument('-h', action='store_true', dest='help_flag_short')
2090
+
2091
+ return parser
2092
+
2093
+ def dispatch(namespace: argparse.Namespace, forwarded: list[str]):
2094
+ """Dispatch parsed arguments to appropriate command parser"""
2095
+ command = namespace.command
2096
+ if command == 'open':
2097
+ if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2098
+ return cmd_help()
2099
+ return parse_open(namespace, forwarded)
2100
+ if command == 'watch':
2101
+ if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2102
+ return cmd_help()
2103
+ return parse_watch(namespace)
2104
+ if command == 'stop':
2105
+ if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2106
+ return cmd_help()
2107
+ return parse_stop(namespace)
2108
+ if command == 'start':
2109
+ if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2110
+ return cmd_help()
2111
+ return parse_start(namespace)
2112
+ if command == 'send':
2113
+ if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
2114
+ return cmd_help()
2115
+ return parse_send(namespace)
2116
+ raise CLIError(f'Unsupported command: {command}')
2117
+
2118
+ def needs_help(args: Sequence[str]) -> bool:
2119
+ """Check if help was requested"""
2120
+ if not args:
2121
+ return True
2122
+ head = args[0]
2123
+ if head in {'help', '--help', '-h'}:
2124
+ return True
2125
+ return False
2126
+
2127
+ # ==================== Command Functions ====================
2128
+
1859
2129
  def cmd_help():
1860
2130
  """Show help text"""
1861
- # Basic help for interactive users
1862
- print("""hcom - Claude Hook Comms
1863
-
1864
- Usage:
1865
- hcom open [n] Launch n Claude instances
1866
- hcom open <agent> Launch named agent from .claude/agents/
1867
- hcom open --prefix <team> n Launch n instances with team prefix
1868
- hcom open --background Launch instances as background processes (-p also works)
1869
- hcom open --claude-args "--model sonnet" Pass claude code CLI flags
1870
- hcom watch View conversation dashboard
1871
- hcom clear Clear and archive conversation
1872
- hcom cleanup Remove hooks from current directory
1873
- hcom cleanup --all Remove hooks from all tracked directories
1874
- hcom kill [instance alias] Kill specific instance
1875
- hcom kill --all Kill all running instances
1876
- hcom help Show this help
1877
-
1878
- Automation:
1879
- hcom send 'msg' Send message to all
1880
- hcom send '@prefix msg' Send to specific instances
1881
- hcom watch --logs Show conversation log
1882
- hcom watch --status Show status of instances
1883
- hcom watch --wait [seconds] Wait for new messages (default 60s)
1884
-
1885
- Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
2131
+ print(HELP_TEXT)
1886
2132
 
1887
2133
  # Additional help for AI assistants
1888
2134
  if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
@@ -1892,26 +2138,27 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
1892
2138
 
1893
2139
  CONCEPT: HCOM launches Claude Code instances in new terminal windows.
1894
2140
  They communicate with each other via a shared conversation.
1895
- You communicate with them via hcom automation commands.
2141
+ You communicate with them via hcom commands.
1896
2142
 
1897
2143
  KEY UNDERSTANDING:
1898
2144
  • Single conversation - All instances share ~/.hcom/hcom.log
1899
- • Messaging - Use 'hcom send "message"' from CLI to send messages to instances
1900
- • Instances receive messages via hooks automatically and send with 'eval $HCOM send "message"'
2145
+ • Messaging - CLI and instances send with hcom send "message"
2146
+ • Instances receive messages via hooks automatically
1901
2147
  • hcom open is directory-specific - always cd to project directory first
1902
- hcom watch --wait outputs existing logs, then waits for the next message, prints it, and exits.
1903
- Times out after [seconds]
1904
- Named agents are custom system prompts created by users/claude code.
1905
- "reviewer" named agent loads .claude/agents/reviewer.md (if it was ever created)
2148
+ Named agents are custom system prompt files created by users/claude code beforehand.
2149
+ Named agents load from .claude/agents/<name>.md - if they have been created
2150
+ hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
1906
2151
 
1907
2152
  LAUNCH PATTERNS:
1908
- hcom open 2 reviewer # 2 generic + 1 reviewer agent
1909
- hcom open reviewer reviewer # 2 separate reviewer instances
1910
- hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
1911
- hcom open --claude-args "--model sonnet" # Pass 'claude' CLI flags
1912
- hcom open --background (or -p) then hcom kill # Detached background process
1913
- hcom watch --status (get sessionid) then hcom open --claude-args "--resume <sessionid>"
1914
- HCOM_INITIAL_PROMPT="do x task" hcom open # initial prompt to instance
2153
+ hcom open 2 # 2 generic instances
2154
+ hcom open -a reviewer # 1 reviewer instance (agent file must already exist)
2155
+ hcom open 3 -a reviewer # 3 reviewer instances
2156
+ hcom open -a reviewer -a tester # 1 reviewer + 1 tester
2157
+ hcom open -t api 2 # Team naming: api-hova7, api-kolec
2158
+ hcom open -- --model sonnet # Pass `claude` CLI flags after --
2159
+ hcom open -p # Detached background (stop with: hcom stop <alias>)
2160
+ hcom open -- --resume <sessionid> # Resume specific session
2161
+ HCOM_INITIAL_PROMPT="task" hcom open # Set initial prompt for instance
1915
2162
 
1916
2163
  @MENTION TARGETING:
1917
2164
  hcom send "message" # Broadcasts to everyone
@@ -1920,12 +2167,9 @@ LAUNCH PATTERNS:
1920
2167
  (Unmatched @mentions broadcast to everyone)
1921
2168
 
1922
2169
  STATUS INDICATORS:
1923
- • ▶ active - instance is working (processing/executing)
1924
- delivered - instance just received a message
1925
- waiting - instance is waiting for new messages
1926
- • ■ blocked - instance is blocked by permission request (needs user approval)
1927
- • ○ inactive - instance is timed out, disconnected, etc
1928
- • ○ unknown - no status information available
2170
+ • ▶ active - processing/executing • ▷ delivered - instance just received a message
2171
+ idle - waiting for new messages • ■ blocked - permission request (needs user approval)
2172
+ inactive - timed out, disconnected, etc ○ unknown
1929
2173
 
1930
2174
  CONFIG:
1931
2175
  Config file (persistent): ~/.hcom/config.json
@@ -1952,112 +2196,114 @@ Run 'claude --help' to see all claude code CLI flags.""")
1952
2196
 
1953
2197
  return 0
1954
2198
 
1955
- def cmd_open(*args):
2199
+ def cmd_open(command: OpenCommand):
1956
2200
  """Launch Claude instances with chat enabled"""
1957
2201
  try:
1958
- # Parse arguments
1959
- instances, prefix, claude_args, background = parse_open_args(list(args))
1960
-
1961
2202
  # Add -p flag and stream-json output for background mode if not already present
1962
- if background and '-p' not in claude_args and '--print' not in claude_args:
2203
+ claude_args = command.claude_args
2204
+ if command.background and '-p' not in claude_args and '--print' not in claude_args:
1963
2205
  claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
1964
-
2206
+
1965
2207
  terminal_mode = get_config_value('terminal_mode', 'new_window')
1966
-
2208
+
2209
+ # Calculate total instances to launch
2210
+ total_instances = command.count * len(command.agents)
2211
+
1967
2212
  # Fail fast for same_terminal with multiple instances
1968
- if terminal_mode == 'same_terminal' and len(instances) > 1:
2213
+ if terminal_mode == 'same_terminal' and total_instances > 1:
1969
2214
  print(format_error(
1970
- f"same_terminal mode cannot launch {len(instances)} instances",
1971
- "Use 'hcom open' for one generic instance or 'hcom open <agent>' for one agent"
2215
+ f"same_terminal mode cannot launch {total_instances} instances",
2216
+ "Use 'hcom open' for one generic instance or 'hcom open -a <agent>' for one agent"
1972
2217
  ), file=sys.stderr)
1973
2218
  return 1
1974
-
2219
+
1975
2220
  try:
1976
2221
  setup_hooks()
1977
2222
  except Exception as e:
1978
2223
  print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
1979
2224
  return 1
1980
-
1981
- log_file = hcom_path(LOG_FILE, ensure_parent=True)
2225
+
2226
+ log_file = hcom_path(LOG_FILE)
1982
2227
  instances_dir = hcom_path(INSTANCES_DIR)
1983
- instances_dir.mkdir(exist_ok=True)
1984
-
2228
+
1985
2229
  if not log_file.exists():
1986
2230
  log_file.touch()
1987
-
2231
+
1988
2232
  # Build environment variables for Claude instances
1989
2233
  base_env = build_claude_env()
1990
2234
 
1991
2235
  # Add prefix-specific hints if provided
1992
- if prefix:
1993
- base_env['HCOM_PREFIX'] = prefix
2236
+ if command.prefix:
2237
+ base_env['HCOM_PREFIX'] = command.prefix
1994
2238
  send_cmd = build_send_command()
1995
- hint = f"To respond to {prefix} group: {send_cmd} '@{prefix} message'"
2239
+ hint = f"To respond to {command.prefix} group: {send_cmd} '@{command.prefix} message'"
1996
2240
  base_env['HCOM_INSTANCE_HINTS'] = hint
1997
- first_use = f"You're in the {prefix} group. Use {prefix} to message: {send_cmd} '@{prefix} message'"
2241
+ first_use = f"You're in the {command.prefix} group. Use {command.prefix} to message: {send_cmd} '@{command.prefix} message'"
1998
2242
  base_env['HCOM_FIRST_USE_TEXT'] = first_use
1999
-
2243
+
2000
2244
  launched = 0
2001
2245
  initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
2002
-
2003
- for _, instance_type in enumerate(instances):
2004
- instance_env = base_env.copy()
2005
-
2006
- # Set unique launch ID for sender detection in cmd_send()
2007
- launch_id = f"{int(time.time())}_{random.randint(10000, 99999)}"
2008
- instance_env['HCOM_LAUNCH_ID'] = launch_id
2009
-
2010
- # Mark background instances via environment with log filename
2011
- if background:
2012
- # Generate unique log filename
2013
- log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
2014
- instance_env['HCOM_BACKGROUND'] = log_filename
2015
-
2016
- # Build claude command
2017
- if instance_type == 'generic':
2018
- # Generic instance - no agent content
2019
- claude_cmd, _ = build_claude_command(
2020
- agent_content=None,
2021
- claude_args=claude_args,
2022
- initial_prompt=initial_prompt
2023
- )
2024
- else:
2025
- # Agent instance
2026
- try:
2027
- agent_content, agent_config = resolve_agent(instance_type)
2028
- # Mark this as a subagent instance for SessionStart hook
2029
- instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
2030
- # Prepend agent instance awareness to system prompt
2031
- agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
2032
- agent_content = agent_prefix + agent_content
2033
- # Use agent's model and tools if specified and not overridden in claude_args
2034
- agent_model = agent_config.get('model')
2035
- agent_tools = agent_config.get('tools')
2246
+
2247
+ # Launch count instances of each agent
2248
+ for agent in command.agents:
2249
+ for _ in range(command.count):
2250
+ instance_type = agent
2251
+ instance_env = base_env.copy()
2252
+
2253
+ # Mark all hcom-launched instances
2254
+ instance_env['HCOM_LAUNCHED'] = '1'
2255
+
2256
+ # Mark background instances via environment with log filename
2257
+ if command.background:
2258
+ # Generate unique log filename
2259
+ log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
2260
+ instance_env['HCOM_BACKGROUND'] = log_filename
2261
+
2262
+ # Build claude command
2263
+ if instance_type == 'generic':
2264
+ # Generic instance - no agent content
2036
2265
  claude_cmd, _ = build_claude_command(
2037
- agent_content=agent_content,
2266
+ agent_content=None,
2038
2267
  claude_args=claude_args,
2039
- initial_prompt=initial_prompt,
2040
- model=agent_model,
2041
- tools=agent_tools
2268
+ initial_prompt=initial_prompt
2042
2269
  )
2043
- # Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
2044
- except (FileNotFoundError, ValueError) as e:
2045
- print(str(e), file=sys.stderr)
2046
- continue
2047
-
2048
- try:
2049
- if background:
2050
- log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
2051
- if log_file:
2052
- print(f"Background instance launched, log: {log_file}")
2053
- launched += 1
2054
2270
  else:
2055
- if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
2056
- launched += 1
2057
- except Exception as e:
2058
- print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
2059
-
2060
- requested = len(instances)
2271
+ # Agent instance
2272
+ try:
2273
+ agent_content, agent_config = resolve_agent(instance_type)
2274
+ # Mark this as a subagent instance for SessionStart hook
2275
+ instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
2276
+ # Prepend agent instance awareness to system prompt
2277
+ agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
2278
+ agent_content = agent_prefix + agent_content
2279
+ # Use agent's model and tools if specified and not overridden in claude_args
2280
+ agent_model = agent_config.get('model')
2281
+ agent_tools = agent_config.get('tools')
2282
+ claude_cmd, _ = build_claude_command(
2283
+ agent_content=agent_content,
2284
+ claude_args=claude_args,
2285
+ initial_prompt=initial_prompt,
2286
+ model=agent_model,
2287
+ tools=agent_tools
2288
+ )
2289
+ # Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
2290
+ except (FileNotFoundError, ValueError) as e:
2291
+ print(str(e), file=sys.stderr)
2292
+ continue
2293
+
2294
+ try:
2295
+ if command.background:
2296
+ log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
2297
+ if log_file:
2298
+ print(f"Background instance launched, log: {log_file}")
2299
+ launched += 1
2300
+ else:
2301
+ if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
2302
+ launched += 1
2303
+ except Exception as e:
2304
+ print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
2305
+
2306
+ requested = total_instances
2061
2307
  failed = requested - launched
2062
2308
 
2063
2309
  if launched == 0:
@@ -2077,21 +2323,22 @@ def cmd_open(*args):
2077
2323
  # Only auto-watch if ALL instances launched successfully
2078
2324
  if terminal_mode == 'new_window' and auto_watch and failed == 0 and is_interactive():
2079
2325
  # Show tips first if needed
2080
- if prefix:
2081
- print(f"\n • Send to {prefix} team: hcom send '@{prefix} message'")
2326
+ if command.prefix:
2327
+ print(f"\n • Send to {command.prefix} team: hcom send '@{command.prefix} message'")
2082
2328
 
2083
2329
  # Clear transition message
2084
2330
  print("\nOpening hcom watch...")
2085
2331
  time.sleep(2) # Brief pause so user sees the message
2086
2332
 
2087
2333
  # Launch interactive watch dashboard in current terminal
2088
- return cmd_watch()
2334
+ watch_cmd = WatchCommand(mode='interactive', wait_seconds=None)
2335
+ return cmd_watch(watch_cmd)
2089
2336
  else:
2090
2337
  tips = [
2091
2338
  "Run 'hcom watch' to view/send in conversation dashboard",
2092
2339
  ]
2093
- if prefix:
2094
- tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
2340
+ if command.prefix:
2341
+ tips.append(f"Send to {command.prefix} team: hcom send '@{command.prefix} message'")
2095
2342
 
2096
2343
  if tips:
2097
2344
  print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
@@ -2109,40 +2356,20 @@ def cmd_open(*args):
2109
2356
  print(str(e), file=sys.stderr)
2110
2357
  return 1
2111
2358
 
2112
- def cmd_watch(*args):
2359
+ def cmd_watch(command: WatchCommand):
2113
2360
  """View conversation dashboard"""
2114
2361
  log_file = hcom_path(LOG_FILE)
2115
2362
  instances_dir = hcom_path(INSTANCES_DIR)
2116
-
2363
+
2117
2364
  if not log_file.exists() and not instances_dir.exists():
2118
2365
  print(format_error("No conversation log found", "Run 'hcom open' first"), file=sys.stderr)
2119
2366
  return 1
2120
-
2121
- # Parse arguments
2122
- show_logs = False
2123
- show_status = False
2124
- wait_timeout = None
2125
-
2126
- i = 0
2127
- while i < len(args):
2128
- arg = args[i]
2129
- if arg == '--logs':
2130
- show_logs = True
2131
- elif arg == '--status':
2132
- show_status = True
2133
- elif arg == '--wait':
2134
- # Check if next arg is a number
2135
- if i + 1 < len(args) and args[i + 1].isdigit():
2136
- wait_timeout = int(args[i + 1])
2137
- i += 1 # Skip the number
2138
- else:
2139
- wait_timeout = 60 # Default
2140
- i += 1
2141
-
2142
- # If wait is specified, enable logs to show the messages
2143
- if wait_timeout is not None:
2144
- show_logs = True
2145
-
2367
+
2368
+ # Determine mode
2369
+ show_logs = command.mode in ('logs', 'wait')
2370
+ show_status = command.mode == 'status'
2371
+ wait_timeout = command.wait_seconds
2372
+
2146
2373
  # Non-interactive mode (no TTY or flags specified)
2147
2374
  if not is_interactive() or show_logs or show_status:
2148
2375
  if show_logs:
@@ -2209,7 +2436,9 @@ def cmd_watch(*args):
2209
2436
  status_counts = {}
2210
2437
 
2211
2438
  for name, data in positions.items():
2212
- status, age = get_instance_status(data)
2439
+ if not should_show_in_watch(data):
2440
+ continue
2441
+ status, age, _ = get_instance_status(data)
2213
2442
  instances[name] = {
2214
2443
  "status": status,
2215
2444
  "age": age.strip() if age else "",
@@ -2218,7 +2447,6 @@ def cmd_watch(*args):
2218
2447
  "last_status": data.get("last_status", ""),
2219
2448
  "last_status_time": data.get("last_status_time", 0),
2220
2449
  "last_status_context": data.get("last_status_context", ""),
2221
- "pid": data.get("pid"),
2222
2450
  "background": bool(data.get("background"))
2223
2451
  }
2224
2452
  status_counts[status] = status_counts.get(status, 0) + 1
@@ -2324,8 +2552,7 @@ def cmd_watch(*args):
2324
2552
  last_pos = log_file.stat().st_size
2325
2553
 
2326
2554
  if message and message.strip():
2327
- sender_name = get_config_value('sender_name', 'bigboss')
2328
- send_message(sender_name, message.strip())
2555
+ cmd_send_cli(message.strip())
2329
2556
  print(f"{FG_GREEN}✓ Sent{RESET}")
2330
2557
 
2331
2558
  print()
@@ -2343,35 +2570,22 @@ def cmd_watch(*args):
2343
2570
 
2344
2571
  def cmd_clear():
2345
2572
  """Clear and archive conversation"""
2346
- log_file = hcom_path(LOG_FILE, ensure_parent=True)
2573
+ log_file = hcom_path(LOG_FILE)
2347
2574
  instances_dir = hcom_path(INSTANCES_DIR)
2348
2575
  archive_folder = hcom_path(ARCHIVE_DIR)
2349
- archive_folder.mkdir(exist_ok=True)
2350
2576
 
2351
- # Clean up temp files from failed atomic writes
2577
+ # cleanup: temp files, old scripts, old outbox files
2578
+ cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
2352
2579
  if instances_dir.exists():
2353
- deleted_count = sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
2354
- if deleted_count > 0:
2355
- print(f"Cleaned up {deleted_count} temp files")
2580
+ sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
2356
2581
 
2357
- # Clean up old script files (older than 24 hours)
2358
2582
  scripts_dir = hcom_path(SCRIPTS_DIR)
2359
2583
  if scripts_dir.exists():
2360
- cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
2361
- script_count = sum(1 for f in scripts_dir.glob('*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
2362
- if script_count > 0:
2363
- print(f"Cleaned up {script_count} old script files")
2364
-
2365
- # Clean up old launch mapping files (older than 24 hours)
2366
- if instances_dir.exists():
2367
- cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
2368
- mapping_count = sum(1 for f in instances_dir.glob('.launch_map_*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
2369
- if mapping_count > 0:
2370
- print(f"Cleaned up {mapping_count} old launch mapping files")
2584
+ sum(1 for f in scripts_dir.glob('*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
2371
2585
 
2372
2586
  # Check if hcom files exist
2373
2587
  if not log_file.exists() and not instances_dir.exists():
2374
- print("No hcom conversation to clear")
2588
+ print("No HCOM conversation to clear")
2375
2589
  return 0
2376
2590
 
2377
2591
  # Archive existing files if they have content
@@ -2385,7 +2599,7 @@ def cmd_clear():
2385
2599
  if has_log or has_instances:
2386
2600
  # Create session archive folder with timestamp
2387
2601
  session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
2388
- session_archive.mkdir(exist_ok=True)
2602
+ session_archive.mkdir(parents=True, exist_ok=True)
2389
2603
 
2390
2604
  # Archive log file
2391
2605
  if has_log:
@@ -2398,16 +2612,12 @@ def cmd_clear():
2398
2612
  # Archive instances
2399
2613
  if has_instances:
2400
2614
  archive_instances = session_archive / INSTANCES_DIR
2401
- archive_instances.mkdir(exist_ok=True)
2615
+ archive_instances.mkdir(parents=True, exist_ok=True)
2402
2616
 
2403
2617
  # Move json files only
2404
2618
  for f in instances_dir.glob('*.json'):
2405
2619
  f.rename(archive_instances / f.name)
2406
2620
 
2407
- # Clean up orphaned mapping files (position files are archived)
2408
- for f in instances_dir.glob('.launch_map_*'):
2409
- f.unlink(missing_ok=True)
2410
-
2411
2621
  archived = True
2412
2622
  else:
2413
2623
  # Clean up empty files/dirs
@@ -2421,7 +2631,7 @@ def cmd_clear():
2421
2631
 
2422
2632
  if archived:
2423
2633
  print(f"Archived to archive/session-{timestamp}/")
2424
- print("Started fresh hcom conversation log")
2634
+ print("Started fresh HCOM conversation log")
2425
2635
  return 0
2426
2636
 
2427
2637
  except Exception as e:
@@ -2450,16 +2660,16 @@ def cleanup_directory_hooks(directory):
2450
2660
  return 1, "Cannot read Claude settings"
2451
2661
 
2452
2662
  hooks_found = False
2453
-
2663
+
2454
2664
  # Include PostToolUse for backward compatibility cleanup
2455
2665
  original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2456
- for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2666
+ for event in LEGACY_HOOK_TYPES)
2457
2667
 
2458
2668
  _remove_hcom_hooks_from_settings(settings)
2459
2669
 
2460
2670
  # Check if any were removed
2461
2671
  new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2462
- for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2672
+ for event in LEGACY_HOOK_TYPES)
2463
2673
  if new_hook_count < original_hook_count:
2464
2674
  hooks_found = True
2465
2675
 
@@ -2482,52 +2692,192 @@ def cleanup_directory_hooks(directory):
2482
2692
  return 1, format_error(f"Cannot modify settings.local.json: {e}")
2483
2693
 
2484
2694
 
2485
- def cmd_kill(*args):
2486
- """Kill instances by name or all with --all"""
2695
+ def cmd_stop(command: StopCommand):
2696
+ """Stop instances, remove hooks, or archive - consolidated stop operations"""
2487
2697
 
2488
- instance_name = args[0] if args and args[0] != '--all' else None
2489
- positions = load_all_positions() if not instance_name else {instance_name: load_instance_position(instance_name)}
2698
+ # Handle special targets
2699
+ if command.target == 'hooking':
2700
+ # hcom stop hooking [--all]
2701
+ if command.close_all_hooks:
2702
+ return cmd_cleanup('--all')
2703
+ else:
2704
+ return cmd_cleanup()
2490
2705
 
2491
- # Filter to instances with PIDs (any instance that's running)
2492
- targets = [(name, data) for name, data in positions.items() if data.get('pid')]
2706
+ elif command.target == 'everything':
2707
+ # hcom stop everything: stop all + archive + remove hooks
2708
+ print("Stopping HCOM for all instances, archiving conversation, and removing hooks...")
2493
2709
 
2494
- if not targets:
2495
- print(f"No running process found for {instance_name}" if instance_name else "No running instances found")
2496
- return 1 if instance_name else 0
2710
+ # Stop all instances
2711
+ positions = load_all_positions()
2712
+ if positions:
2713
+ for instance_name in positions.keys():
2714
+ disable_instance(instance_name)
2715
+ print(f"Stopped HCOM for {len(positions)} instance(s)")
2497
2716
 
2498
- killed_count = 0
2499
- for target_name, target_data in targets:
2500
- status, age = get_instance_status(target_data)
2501
- instance_type = "background" if target_data.get('background') else "foreground"
2717
+ # Archive conversation
2718
+ clear_result = cmd_clear()
2502
2719
 
2503
- pid = int(target_data['pid'])
2504
- try:
2505
- # Try graceful termination first
2506
- terminate_process(pid, force=False)
2507
-
2508
- # Wait for process to exit gracefully
2509
- for _ in range(20):
2510
- time.sleep(KILL_CHECK_INTERVAL)
2511
- if not is_process_alive(pid):
2512
- # Process terminated successfully
2513
- break
2514
- else:
2515
- # Process didn't die from graceful attempt, force kill
2516
- terminate_process(pid, force=True)
2517
- time.sleep(0.1)
2720
+ # Remove hooks from all directories
2721
+ cleanup_result = cmd_cleanup('--all')
2518
2722
 
2519
- print(f"Killed {target_name} ({instance_type}, {status}{age}, PID {pid})")
2520
- killed_count += 1
2521
- except (TypeError, ValueError) as e:
2522
- print(f"Process {pid} invalid: {e}")
2723
+ return max(clear_result, cleanup_result)
2523
2724
 
2524
- # Mark instance as killed
2525
- update_instance_position(target_name, {'pid': None})
2526
- set_status(target_name, 'killed')
2725
+ elif command.target == 'all':
2726
+ # hcom stop all: stop all instances + archive
2727
+ positions = load_all_positions()
2527
2728
 
2528
- if not instance_name:
2529
- print(f"Killed {killed_count} instance(s)")
2729
+ if not positions:
2730
+ print("No instances found")
2731
+ # Still archive if there's conversation history
2732
+ return cmd_clear()
2733
+
2734
+ stopped_count = 0
2735
+ bg_logs = []
2736
+ for instance_name, instance_data in positions.items():
2737
+ if instance_data.get('enabled', False):
2738
+ disable_instance(instance_name)
2739
+ print(f"Stopped HCOM for {instance_name}")
2740
+ stopped_count += 1
2741
+
2742
+ # Track background logs
2743
+ if instance_data.get('background'):
2744
+ log_file = instance_data.get('background_log_file', '')
2745
+ if log_file:
2746
+ bg_logs.append((instance_name, log_file))
2747
+
2748
+ if stopped_count == 0:
2749
+ print("All instances already stopped")
2750
+ else:
2751
+ print(f"Stopped {stopped_count} instance(s)")
2752
+
2753
+ # Show background logs if any
2754
+ if bg_logs:
2755
+ print("\nBackground logs:")
2756
+ for name, log_file in bg_logs:
2757
+ print(f" {name}: {log_file}")
2758
+ print("\nMonitor: tail -f <log_file>")
2759
+ print("Force stop: hcom stop --force all")
2760
+
2761
+ # Archive conversation
2762
+ return cmd_clear()
2763
+
2764
+ else:
2765
+ # hcom stop [alias] or hcom stop (self)
2766
+
2767
+ # Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
2768
+ if not check_and_update_hooks():
2769
+ return 1
2770
+
2771
+ # Stop specific instance or self
2772
+ # Get instance name from injected session or target
2773
+ if command._hcom_session and not command.target:
2774
+ instance_name, _ = resolve_instance_name(command._hcom_session, os.environ.get('HCOM_PREFIX'))
2775
+ else:
2776
+ instance_name = command.target
2777
+
2778
+ position = load_instance_position(instance_name) if instance_name else None
2779
+
2780
+ if not instance_name:
2781
+ print("Error: Could not determine instance. Run inside Claude Code or specify alias. Or run hcom stop <alias> or hcom stop all")
2782
+ return 1
2783
+
2784
+ if not position:
2785
+ print(f"No instance found for {instance_name}")
2786
+ return 1
2787
+
2788
+ # Skip already stopped instances (unless forcing)
2789
+ if not position.get('enabled', False) and not command.force:
2790
+ print(f"HCOM already stopped for {instance_name}")
2791
+ return 0
2792
+
2793
+ # Disable instance (optionally with force)
2794
+ disable_instance(instance_name, force=command.force)
2530
2795
 
2796
+ if command.force:
2797
+ print(f"⚠️ Force stopped HCOM for {instance_name}.")
2798
+ print(f" Bash tool use is now DENIED. Instance is locked down.")
2799
+ print(f" To restart: hcom start {instance_name}")
2800
+ else:
2801
+ print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
2802
+
2803
+ # Show background log location if applicable
2804
+ if position.get('background'):
2805
+ log_file = position.get('background_log_file', '')
2806
+ if log_file:
2807
+ print(f"\nBackground log: {log_file}")
2808
+ print(f"Monitor: tail -f {log_file}")
2809
+ if not command.force:
2810
+ print(f"Force stop: hcom stop --force {instance_name}")
2811
+
2812
+ return 0
2813
+
2814
+ def cmd_start(command: StartCommand):
2815
+ """Enable HCOM participation for instances"""
2816
+
2817
+ # Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
2818
+ if not check_and_update_hooks():
2819
+ return 1
2820
+
2821
+ # Get instance name from injected session or target
2822
+ if command._hcom_session and not command.target:
2823
+ instance_name, existing_data = resolve_instance_name(command._hcom_session, os.environ.get('HCOM_PREFIX'))
2824
+
2825
+ # Create instance if it doesn't exist (opt-in for vanilla instances)
2826
+ if not existing_data:
2827
+ initialize_instance_in_position_file(instance_name, command._hcom_session)
2828
+ # Enable instance (clears all stop flags)
2829
+ enable_instance(instance_name)
2830
+ print(f"Started HCOM for this instance. Your alias is: {instance_name}")
2831
+ else:
2832
+ # Skip already started instances
2833
+ if existing_data.get('enabled', False):
2834
+ print(f"HCOM already started for {instance_name}")
2835
+ return 0
2836
+
2837
+ # Re-enabling existing instance
2838
+ enable_instance(instance_name)
2839
+ print(f"Started HCOM for {instance_name}. Rejoined chat.")
2840
+
2841
+ return 0
2842
+
2843
+ # Handle hooking target
2844
+ if command.target == 'hooking':
2845
+ # hcom start hooking: install hooks in current directory
2846
+ if setup_hooks():
2847
+ print("HCOM hooks installed in current directory")
2848
+ print("Hooks active on next Claude Code launch in this directory")
2849
+ return 0
2850
+ else:
2851
+ return 1
2852
+
2853
+ # CLI path: start specific instance
2854
+ positions = load_all_positions()
2855
+
2856
+ # Handle missing target from external CLI
2857
+ if not command.target:
2858
+ print("Error: No instance specified & Not run by Claude Code\n")
2859
+ print("Run by Claude Code: 'hcom start' starts HCOM for the current instance")
2860
+ print("From anywhere: 'hcom start <alias>'\n")
2861
+ print("To launch new instances: 'hcom open'")
2862
+ return 1
2863
+
2864
+ # Start specific instance
2865
+ instance_name = command.target
2866
+ position = positions.get(instance_name)
2867
+
2868
+ if not position:
2869
+ print(f"Instance not found: {instance_name}")
2870
+ return 1
2871
+
2872
+ # Skip already started instances
2873
+ if position.get('enabled', False):
2874
+ print(f"HCOM already started for {instance_name}")
2875
+ return 0
2876
+
2877
+ # Enable instance (clears all stop flags)
2878
+ enable_instance(instance_name)
2879
+
2880
+ print(f"Started HCOM for {instance_name}. Rejoined chat.")
2531
2881
  return 0
2532
2882
 
2533
2883
  def cmd_cleanup(*args):
@@ -2546,7 +2896,7 @@ def cmd_cleanup(*args):
2546
2896
  print(f"Warning: Could not read current instances: {e}")
2547
2897
 
2548
2898
  if not directories:
2549
- print("No directories found in current hcom tracking")
2899
+ print("No directories found in current HCOM tracking")
2550
2900
  return 0
2551
2901
 
2552
2902
  print(f"Found {len(directories)} unique directories to check")
@@ -2587,8 +2937,48 @@ def cmd_cleanup(*args):
2587
2937
  print(message)
2588
2938
  return exit_code
2589
2939
 
2590
- def cmd_send(message):
2591
- """Send message to hcom"""
2940
+ def check_and_update_hooks() -> bool:
2941
+ """Verify hooks are correct when running inside Claude Code, reinstall if needed.
2942
+ Returns True if hooks are good (continue), False if hooks were updated (restart needed)."""
2943
+ if os.environ.get('CLAUDECODE') != '1':
2944
+ return True # Not in Claude Code, continue normally
2945
+
2946
+ # Check both cwd and home for hooks
2947
+ cwd_settings = Path.cwd() / '.claude' / 'settings.local.json'
2948
+ home_settings = Path.home() / '.claude' / 'settings.local.json'
2949
+
2950
+ # If hooks are correctly installed, continue
2951
+ if verify_hooks_installed(cwd_settings) or verify_hooks_installed(home_settings):
2952
+ return True
2953
+
2954
+ # Hooks missing or incorrect - reinstall them
2955
+ try:
2956
+ setup_hooks()
2957
+ print("Hooks updated. Restart Claude Code to use HCOM.", file=sys.stderr)
2958
+ except Exception as e:
2959
+ print(f"Failed to update hooks: {e}", file=sys.stderr)
2960
+ print("Try running: hcom open from normal terminal", file=sys.stderr)
2961
+ return False
2962
+
2963
+ def cmd_send(command: SendCommand, force_cli=False):
2964
+ """Send message to hcom, force cli for config sender instead of instance generated name"""
2965
+
2966
+ # Always verify hooks when running from Claude Code (catches broken hooks regardless of path)
2967
+ if not check_and_update_hooks():
2968
+ return 1
2969
+
2970
+ # Handle resume command - pass caller session_id
2971
+ if command.resume_alias:
2972
+ caller_session = command._hcom_session # May be None for CLI
2973
+ return cmd_resume_merge(command.resume_alias, caller_session)
2974
+
2975
+ message = command.message
2976
+
2977
+ # Check message is provided
2978
+ if not message:
2979
+ print(format_error("No message provided"), file=sys.stderr)
2980
+ return 1
2981
+
2592
2982
  # Check if hcom files exist
2593
2983
  log_file = hcom_path(LOG_FILE)
2594
2984
  instances_dir = hcom_path(INSTANCES_DIR)
@@ -2609,80 +2999,107 @@ def cmd_send(message):
2609
2999
  try:
2610
3000
  positions = load_all_positions()
2611
3001
  all_instances = list(positions.keys())
3002
+ sender_name = get_config_value('sender_name', 'bigboss')
3003
+ all_names = all_instances + [sender_name]
2612
3004
  unmatched = [m for m in mentions
2613
- if not any(name.lower().startswith(m.lower()) for name in all_instances)]
3005
+ if not any(name.lower().startswith(m.lower()) for name in all_names)]
2614
3006
  if unmatched:
2615
3007
  print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
2616
3008
  except Exception:
2617
3009
  pass # Don't fail on warning
2618
3010
 
2619
- # Determine sender: lookup by launch_id, fallback to config
2620
- sender_name = None
2621
- launch_id = os.environ.get('HCOM_LAUNCH_ID')
2622
- if launch_id:
3011
+ # Determine sender from injected session_id or CLI
3012
+ if command._hcom_session and not force_cli:
3013
+ # Instance context - get name from session_id
2623
3014
  try:
2624
- mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
2625
- if mapping_file.exists():
2626
- sender_name = mapping_file.read_text(encoding='utf-8').strip()
2627
- except Exception:
2628
- pass
3015
+ sender_name = get_display_name(command._hcom_session)
3016
+ except (ValueError, Exception) as e:
3017
+ print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
3018
+ return 1
2629
3019
 
2630
- if not sender_name:
2631
- sender_name = get_config_value('sender_name', 'bigboss')
3020
+ instance_data = load_instance_position(sender_name)
2632
3021
 
2633
- if send_message(sender_name, message):
2634
- # For instances: check for new messages and display immediately
2635
- if launch_id: # Only for instances with HCOM_LAUNCH_ID
2636
- messages = get_unread_messages(sender_name, update_position=True)
2637
- if messages:
2638
- max_msgs = get_config_value('max_messages_per_delivery', 50)
2639
- messages_to_show = messages[:max_msgs]
2640
- formatted = format_hook_messages(messages_to_show, sender_name)
2641
- print(f"Message sent\n\n{formatted}", file=sys.stderr)
2642
- else:
2643
- print("Message sent", file=sys.stderr)
3022
+ # Initialize instance if doesn't exist (first use)
3023
+ if not instance_data:
3024
+ initialize_instance_in_position_file(sender_name, command._hcom_session)
3025
+ instance_data = load_instance_position(sender_name)
3026
+
3027
+ # Check force_closed
3028
+ if instance_data.get('force_closed'):
3029
+ print(format_error(f"HCOM force stopped for this instance. To recover, delete instance file: rm ~/.hcom/instances/{sender_name}.json"), file=sys.stderr)
3030
+ return 1
3031
+
3032
+ # Check enabled state
3033
+ if not instance_data.get('enabled', False):
3034
+ print(format_error("HCOM not started for this instance. To send a message first run: 'hcom start' then use hcom send"), file=sys.stderr)
3035
+ return 1
3036
+
3037
+ # Send message
3038
+ if not send_message(sender_name, message):
3039
+ print(format_error("Failed to send message"), file=sys.stderr)
3040
+ return 1
3041
+
3042
+ # Show unread messages
3043
+ messages = get_unread_messages(sender_name, update_position=True)
3044
+ if messages:
3045
+ max_msgs = get_config_value('max_messages_per_delivery', 50)
3046
+ formatted = format_hook_messages(messages[:max_msgs], sender_name)
3047
+ print(f"Message sent\n\n{formatted}", file=sys.stderr)
2644
3048
  else:
2645
- # Bigboss: just confirm send
2646
3049
  print("Message sent", file=sys.stderr)
2647
-
3050
+
2648
3051
  # Show cli_hints if configured (non-interactive mode)
2649
3052
  if not is_interactive():
2650
3053
  show_cli_hints()
2651
-
3054
+
2652
3055
  return 0
2653
3056
  else:
2654
- print(format_error("Failed to send message"), file=sys.stderr)
2655
- return 1
3057
+ # CLI context - no session_id or force_cli=True
3058
+ sender_name = get_config_value('sender_name', 'bigboss')
2656
3059
 
2657
- def cmd_resume_merge(alias: str) -> int:
2658
- """Resume/merge current instance into an existing instance by alias.
3060
+ if not send_message(sender_name, message):
3061
+ print(format_error("Failed to send message"), file=sys.stderr)
3062
+ return 1
3063
+
3064
+ print(f"✓ Sent from {sender_name}", file=sys.stderr)
3065
+
3066
+ # Show cli_hints if configured (non-interactive mode)
3067
+ if not is_interactive():
3068
+ show_cli_hints()
2659
3069
 
2660
- INTERNAL COMMAND: Only called via '$HCOM send --resume alias' during implicit resume workflow.
3070
+ return 0
3071
+
3072
+ def cmd_send_cli(message):
3073
+ """Force CLI sender (skip outbox, use config sender name)"""
3074
+ command = SendCommand(message=message, resume_alias=None)
3075
+ return cmd_send(command, force_cli=True)
3076
+
3077
+ def cmd_resume_merge(alias: str, caller_session: str | None = None) -> int:
3078
+ """Resume/merge current instance into an existing instance by alias.
3079
+ INTERNAL COMMAND: Only called via 'hcom send --resume alias' during implicit resume workflow.
2661
3080
  Not meant for direct CLI usage.
3081
+ Args:
3082
+ alias: Target instance alias to merge into
3083
+ caller_session: Session ID of caller (injected by PreToolUse hook) or None for CLI
2662
3084
  """
2663
- # Get current instance name via launch_id mapping (same mechanism as cmd_send)
2664
- # The mapping is created by init_hook_context() when hooks run
2665
- launch_id = os.environ.get('HCOM_LAUNCH_ID')
2666
- if not launch_id:
2667
- print(format_error("Not in HCOM instance context - no launch ID"), file=sys.stderr)
3085
+ # If caller_session provided (from hcom send --resume), use it
3086
+ if caller_session:
3087
+ instance_name = get_display_name(caller_session)
3088
+ else:
3089
+ # CLI path - no session context
3090
+ print(format_error("Not in HCOM instance context"), file=sys.stderr)
2668
3091
  return 1
2669
3092
 
2670
- instance_name = None
2671
- try:
2672
- mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
2673
- if mapping_file.exists():
2674
- instance_name = mapping_file.read_text(encoding='utf-8').strip()
2675
- except Exception:
2676
- pass
2677
-
2678
3093
  if not instance_name:
2679
3094
  print(format_error("Could not determine instance name"), file=sys.stderr)
2680
3095
  return 1
2681
3096
 
2682
- # Sanitize alias: only allow alphanumeric, dash, underscore
3097
+ # Sanitize alias: must be valid instance name format
3098
+ # Base: 5 lowercase alphanumeric (e.g., hova3)
3099
+ # Prefixed: {prefix}-{5 chars} (e.g., api-hova3, cool-team-kolec)
2683
3100
  # This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
2684
- if not re.match(r'^[A-Za-z0-9\-_]+$', alias):
2685
- print(format_error("Invalid alias format. Use alphanumeric, dash, or underscore only"), file=sys.stderr)
3101
+ if not re.match(r'^([a-zA-Z0-9_-]+-)?[a-z0-9]{5}$', alias):
3102
+ print(format_error("Invalid alias format. Must be 5-char instance name or prefix-name format"), file=sys.stderr)
2686
3103
  return 1
2687
3104
 
2688
3105
  # Attempt to merge current instance into target alias
@@ -2691,7 +3108,14 @@ def cmd_resume_merge(alias: str) -> int:
2691
3108
  # Handle results
2692
3109
  if not status:
2693
3110
  # Empty status means names matched (from_name == to_name)
2694
- status = f"[SUCCESS] ✓ Already using alias {alias}"
3111
+ status = f"[SUCCESS] ✓ Already using HCOM alias {alias}. Rejoined chat."
3112
+ elif status.startswith('[SUCCESS]'):
3113
+ # Merge successful - update message
3114
+ status = f"[SUCCESS] ✓ Resumed HCOM as {alias}. Rejoined chat."
3115
+
3116
+ # If merge successful, enable instance (clears session_ended and stop flags)
3117
+ if status.startswith('[SUCCESS]'):
3118
+ enable_instance(alias)
2695
3119
 
2696
3120
  # Print status and return
2697
3121
  print(status, file=sys.stderr)
@@ -2717,143 +3141,168 @@ def format_hook_messages(messages, instance_name):
2717
3141
 
2718
3142
  # ==================== Hook Handlers ====================
2719
3143
 
3144
+ def detect_session_scenario(
3145
+ hook_type: str | None,
3146
+ session_id: str,
3147
+ source: str,
3148
+ existing_data: dict | None
3149
+ ) -> SessionScenario | None:
3150
+ """
3151
+ Detect session startup scenario explicitly.
3152
+
3153
+ Returns:
3154
+ SessionScenario for definitive scenarios
3155
+ None for deferred decision (SessionStart with wrong session_id)
3156
+ """
3157
+ if existing_data is not None:
3158
+ # Found existing instance with matching session_id
3159
+ return SessionScenario.MATCHED_RESUME
3160
+
3161
+ if hook_type == 'sessionstart' and source == 'resume':
3162
+ # SessionStart on resume without match = wrong session_id
3163
+ # Don't know if truly unmatched yet - UserPromptSubmit will decide
3164
+ return None # Deferred decision
3165
+
3166
+ if hook_type == 'userpromptsubmit' and source == 'resume':
3167
+ # UserPromptSubmit on resume without match = definitively unmatched
3168
+ return SessionScenario.UNMATCHED_RESUME
3169
+
3170
+ # Normal startup
3171
+ return SessionScenario.FRESH_START
3172
+
3173
+
3174
+ def should_create_instance_file(scenario: SessionScenario | None, hook_type: str | None) -> bool:
3175
+ """
3176
+ Decide whether to create instance file NOW.
3177
+
3178
+ Simplified: Only UserPromptSubmit creates instances.
3179
+ SessionStart just shows minimal message and tracks status.
3180
+ """
3181
+ # Only UserPromptSubmit creates instances
3182
+ if hook_type != 'userpromptsubmit':
3183
+ return False
3184
+
3185
+ # Create for new scenarios only (not matched resume which already exists)
3186
+ return scenario in (SessionScenario.FRESH_START, SessionScenario.UNMATCHED_RESUME)
3187
+
3188
+
2720
3189
  def init_hook_context(hook_data, hook_type=None):
2721
- """Initialize instance context - shared by post/stop/notify hooks"""
2722
- import time
3190
+ """
3191
+ Initialize instance context with explicit scenario detection.
2723
3192
 
3193
+ Flow:
3194
+ 1. Resolve instance name (search by session_id, generate if not found)
3195
+ 2. Detect scenario (fresh/matched/unmatched/deferred)
3196
+ 3. Decide whether to create file NOW
3197
+ 4. Return context with all decisions made
3198
+ """
2724
3199
  session_id = hook_data.get('session_id', '')
2725
3200
  transcript_path = hook_data.get('transcript_path', '')
3201
+ source = hook_data.get('source', 'startup')
2726
3202
  prefix = os.environ.get('HCOM_PREFIX')
2727
3203
 
2728
- instances_dir = hcom_path(INSTANCES_DIR)
2729
- instance_name = None
2730
- merged_state = None
2731
-
2732
- # Check if current session_id matches any existing instance
2733
- # This maintains identity after resume/merge operations
2734
- if not instance_name and session_id and instances_dir.exists():
2735
- for instance_file in instances_dir.glob("*.json"):
2736
- try:
2737
- data = load_instance_position(instance_file.stem)
2738
- if session_id == data.get('session_id'):
2739
- instance_name = instance_file.stem
2740
- merged_state = data
2741
- log_hook_error(f'DEBUG: Session_id {session_id[:8]} matched {instance_file.stem}, reusing that name')
2742
- break
2743
- except (json.JSONDecodeError, OSError, KeyError):
2744
- continue
2745
-
2746
- # If not found or not resuming, generate new name from session_id
2747
- if not instance_name:
2748
- instance_name = get_display_name(session_id, prefix)
2749
- # DEBUG: Log name generation
2750
- log_hook_error(f'DEBUG: Generated instance_name={instance_name} from session_id={session_id[:8] if session_id else "None"}')
2751
-
2752
- # Save launch_id → instance_name mapping for cmd_send()
2753
- launch_id = os.environ.get('HCOM_LAUNCH_ID')
2754
- if launch_id:
2755
- try:
2756
- mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}', ensure_parent=True)
2757
- mapping_file.write_text(instance_name, encoding='utf-8')
2758
- log_hook_error(f'DEBUG: FINAL - Wrote launch_map_{launch_id} → {instance_name} (session_id={session_id[:8] if session_id else "None"})')
2759
- except Exception:
2760
- pass # Non-critical
3204
+ # Step 1: Resolve instance name
3205
+ instance_name, existing_data = resolve_instance_name(session_id, prefix)
2761
3206
 
2762
3207
  # Save migrated data if we have it
2763
- if merged_state:
2764
- save_instance_position(instance_name, merged_state)
3208
+ if existing_data:
3209
+ save_instance_position(instance_name, existing_data)
3210
+
3211
+ # Step 2: Detect scenario
3212
+ scenario = detect_session_scenario(hook_type, session_id, source, existing_data)
2765
3213
 
2766
- # Check if instance is brand new or pre-existing (before creation (WWJD))
3214
+ # Check if instance is brand new (before creation - for bypass logic)
2767
3215
  instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
2768
3216
  is_new_instance = not instance_file.exists()
2769
3217
 
2770
- # Skip instance creation for unmatched SessionStart resumes (prevents orphans)
2771
- # Instance will be created in UserPromptSubmit with correct session_id
2772
- should_create_instance = not (
2773
- hook_type == 'sessionstart' and
2774
- hook_data.get('source', 'startup') == 'resume' and not merged_state
2775
- )
2776
- if should_create_instance:
3218
+
3219
+ # Step 3: Decide creation
3220
+ should_create = should_create_instance_file(scenario, hook_type)
3221
+
3222
+
3223
+ if should_create:
2777
3224
  initialize_instance_in_position_file(instance_name, session_id)
2778
- existing_data = load_instance_position(instance_name) if should_create_instance else {}
2779
3225
 
2780
- # Prepare updates
3226
+ # Step 4: Build updates dict
2781
3227
  updates: dict[str, Any] = {
2782
3228
  'directory': str(Path.cwd()),
2783
3229
  }
2784
3230
 
2785
- # Update session_id (overwrites previous)
2786
3231
  if session_id:
2787
3232
  updates['session_id'] = session_id
2788
3233
 
2789
- # Update transcript_path to current
2790
3234
  if transcript_path:
2791
3235
  updates['transcript_path'] = transcript_path
2792
3236
 
2793
- # Always update PID to current (fixes stale PID on implicit resume)
2794
- updates['pid'] = os.getppid()
2795
-
2796
- # Add background status if applicable
2797
3237
  bg_env = os.environ.get('HCOM_BACKGROUND')
2798
3238
  if bg_env:
2799
3239
  updates['background'] = True
2800
3240
  updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
2801
3241
 
2802
- # Return flags indicating resume state
2803
- is_resume_match = merged_state is not None
2804
- return instance_name, updates, existing_data, is_resume_match, is_new_instance
3242
+ # Return compatible with existing callers
3243
+ is_resume_match = (scenario == SessionScenario.MATCHED_RESUME)
3244
+
3245
+
3246
+ return instance_name, updates, is_resume_match, is_new_instance
3247
+
3248
+ def pretooluse_decision(decision: str, reason: str) -> None:
3249
+ """Exit PreToolUse hook with permission decision"""
3250
+ output = {
3251
+ "hookSpecificOutput": {
3252
+ "hookEventName": "PreToolUse",
3253
+ "permissionDecision": decision,
3254
+ "permissionDecisionReason": reason
3255
+ }
3256
+ }
3257
+ print(json.dumps(output, ensure_ascii=False))
3258
+ sys.exit(EXIT_SUCCESS)
2805
3259
 
2806
3260
  def handle_pretooluse(hook_data, instance_name, updates):
2807
- """Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
3261
+ """Handle PreToolUse hook - check force_closed, inject session_id"""
3262
+ instance_data = load_instance_position(instance_name)
2808
3263
  tool_name = hook_data.get('tool_name', '')
3264
+ session_id = hook_data.get('session_id', '')
2809
3265
 
2810
- # Non-HCOM_SEND tools: record status (they'll run without permission check)
2811
- set_status(instance_name, 'tool_pending', tool_name)
3266
+ # FORCE CLOSE CHECK - deny ALL tools
3267
+ if instance_data.get('force_closed'):
3268
+ pretooluse_decision("deny", f"FORCE CLOSED: The user has force stopped HCOM for this instance (you, {instance_name}). Stop using all tools immediately and wait for user instructions.")
2812
3269
 
2813
- import time
3270
+ # Record status for tool execution tracking (only if enabled)
3271
+ if instance_data.get('enabled', False):
3272
+ set_status(instance_name, 'tool_pending', tool_name)
2814
3273
 
2815
- # Handle HCOM commands in Bash
2816
- if tool_name == 'Bash':
3274
+ # Inject session_id into hcom send/stop/start commands via updatedInput
3275
+ if tool_name == 'Bash' and session_id:
2817
3276
  command = hook_data.get('tool_input', {}).get('command', '')
2818
- script_path = str(Path(__file__).resolve())
2819
-
2820
- # === Auto-approve ALL '$HCOM send' commands (including --resume) ===
2821
- # This includes:
2822
- # - $HCOM send "message" (normal messaging between instances)
2823
- # - $HCOM send --resume alias (resume/merge operation)
2824
- if ('$HCOM send' in command or
2825
- 'hcom send' in command or
2826
- (script_path in command and ' send ' in command)):
3277
+
3278
+ # Match: (hcom|uvx hcom|python X hcom.py|X hcom.py) (send|stop|start)
3279
+ # Handles: hcom, uvx hcom, python hcom.py, python /path/to/hcom.py, hcom.py, /path/to/hcom.py
3280
+ hcom_pattern = r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+(send|stop|start)\b'
3281
+
3282
+ # Check if command contains any hcom invocations
3283
+ if re.search(hcom_pattern, command):
3284
+ # Inject session_id after EACH hcom command (handles chained commands)
3285
+ modified_command = re.sub(hcom_pattern, rf'\g<0> --_hcom_session {session_id}', command)
3286
+
2827
3287
  output = {
2828
3288
  "hookSpecificOutput": {
2829
3289
  "hookEventName": "PreToolUse",
2830
3290
  "permissionDecision": "allow",
2831
- "permissionDecisionReason": "HCOM send command auto-approved"
3291
+ "updatedInput": {
3292
+ "command": modified_command
3293
+ }
2832
3294
  }
2833
3295
  }
2834
3296
  print(json.dumps(output, ensure_ascii=False))
2835
3297
  sys.exit(EXIT_SUCCESS)
2836
3298
 
2837
3299
 
2838
- def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
2839
- """Safely exit stop hook with proper status tracking"""
2840
- try:
2841
- set_status(instance_name, 'stop_exit')
2842
- except (OSError, PermissionError):
2843
- pass # Silently handle any errors
2844
- sys.exit(code)
2845
3300
 
2846
3301
  def handle_stop(hook_data, instance_name, updates):
2847
3302
  """Handle Stop hook - poll for messages and deliver"""
2848
- import time as time_module
2849
-
2850
- parent_pid = os.getppid()
2851
- log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
2852
- log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
2853
-
2854
3303
 
2855
3304
  try:
2856
- entry_time = time_module.time()
3305
+ entry_time = time.time()
2857
3306
  updates['last_stop'] = entry_time
2858
3307
  timeout = get_config_value('wait_timeout', 1800)
2859
3308
  updates['wait_timeout'] = timeout
@@ -2864,32 +3313,32 @@ def handle_stop(hook_data, instance_name, updates):
2864
3313
  except Exception as e:
2865
3314
  log_hook_error(f'stop:update_instance_position({instance_name})', e)
2866
3315
 
2867
- start_time = time_module.time()
2868
- log_hook_error(f'stop:start_time_pid_{os.getpid()}')
3316
+ start_time = time.time()
2869
3317
 
2870
3318
  try:
2871
3319
  loop_count = 0
2872
- # STEP 4: Actual polling loop - this IS the holding pattern
2873
- while time_module.time() - start_time < timeout:
3320
+ last_heartbeat = start_time
3321
+ # Actual polling loop - this IS the holding pattern
3322
+ while time.time() - start_time < timeout:
2874
3323
  if loop_count == 0:
2875
- time_module.sleep(0.1) # Initial wait before first poll
3324
+ time.sleep(0.1) # Initial wait before first poll
2876
3325
  loop_count += 1
2877
3326
 
2878
- # Check if parent is alive
2879
- if not IS_WINDOWS and os.getppid() == 1:
2880
- log_hook_error(f'stop:parent_died_pid_{os.getpid()}')
2881
- safe_exit_with_status(instance_name, EXIT_SUCCESS)
3327
+ # Load instance data once per poll
3328
+ instance_data = load_instance_position(instance_name)
2882
3329
 
2883
- parent_alive = is_parent_alive(parent_pid)
2884
- if not parent_alive:
2885
- log_hook_error(f'stop:parent_not_alive_pid_{os.getpid()}')
2886
- safe_exit_with_status(instance_name, EXIT_SUCCESS)
3330
+ # Check if session ended (SessionEnd hook fired) - exit without changing status
3331
+ if instance_data.get('session_ended'):
3332
+ sys.exit(EXIT_SUCCESS) # Don't overwrite session_ended status
2887
3333
 
2888
- # Check if user input is pending - exit cleanly if so
2889
- user_input_signal = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
2890
- if user_input_signal.exists():
2891
- log_hook_error(f'stop:user_input_pending_exiting_pid_{os.getpid()}')
2892
- safe_exit_with_status(instance_name, EXIT_SUCCESS)
3334
+ # Check if user input is pending - exit cleanly if recent input
3335
+ last_user_input = instance_data.get('last_user_input', 0)
3336
+ if time.time() - last_user_input < 0.2:
3337
+ sys.exit(EXIT_SUCCESS) # Don't overwrite status - let current status remain
3338
+
3339
+ # Check if closed - exit cleanly
3340
+ if not instance_data.get('enabled', False):
3341
+ sys.exit(EXIT_SUCCESS) # Preserve 'stopped' status set by cmd_stop
2893
3342
 
2894
3343
  # Check for new messages and deliver
2895
3344
  if messages := get_unread_messages(instance_name, update_position=True):
@@ -2897,19 +3346,20 @@ def handle_stop(hook_data, instance_name, updates):
2897
3346
  reason = format_hook_messages(messages_to_show, instance_name)
2898
3347
  set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
2899
3348
 
2900
- log_hook_error(f'stop:delivering_message_pid_{os.getpid()}')
2901
3349
  output = {"decision": "block", "reason": reason}
2902
3350
  print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2903
3351
  sys.exit(EXIT_BLOCK)
2904
3352
 
2905
- # Update heartbeat
2906
- try:
2907
- update_instance_position(instance_name, {'last_stop': time_module.time()})
2908
- # log_hook_error(f'hb_pid_{os.getpid()}')
2909
- except Exception as e:
2910
- log_hook_error(f'stop:heartbeat_update({instance_name})', e)
3353
+ # Update heartbeat every 0.5 seconds for staleness detection
3354
+ now = time.time()
3355
+ if now - last_heartbeat >= 0.5:
3356
+ try:
3357
+ update_instance_position(instance_name, {'last_stop': now})
3358
+ last_heartbeat = now
3359
+ except Exception as e:
3360
+ log_hook_error(f'stop:heartbeat_update({instance_name})', e)
2911
3361
 
2912
- time_module.sleep(STOP_HOOK_POLL_INTERVAL)
3362
+ time.sleep(STOP_HOOK_POLL_INTERVAL)
2913
3363
 
2914
3364
  except Exception as loop_e:
2915
3365
  # Log polling loop errors but continue to cleanup
@@ -2921,7 +3371,7 @@ def handle_stop(hook_data, instance_name, updates):
2921
3371
  except Exception as e:
2922
3372
  # Log error and exit gracefully
2923
3373
  log_hook_error('handle_stop', e)
2924
- safe_exit_with_status(instance_name, EXIT_SUCCESS)
3374
+ sys.exit(EXIT_SUCCESS) # Preserve previous status on exception
2925
3375
 
2926
3376
  def handle_notify(hook_data, instance_name, updates):
2927
3377
  """Handle Notification hook - track permission requests"""
@@ -2929,124 +3379,186 @@ def handle_notify(hook_data, instance_name, updates):
2929
3379
  update_instance_position(instance_name, updates)
2930
3380
  set_status(instance_name, 'blocked', hook_data.get('message', ''))
2931
3381
 
3382
+ def wait_for_stop_exit(instance_name, max_wait=0.2):
3383
+ """Wait for Stop hook to exit. Returns wait time in ms."""
3384
+ start = time.time()
3385
+
3386
+ while time.time() - start < max_wait:
3387
+ time.sleep(0.01)
3388
+
3389
+ data = load_instance_position(instance_name)
3390
+ last_stop_age = time.time() - data.get('last_stop', 0)
3391
+
3392
+ if last_stop_age > 0.2:
3393
+ return int((time.time() - start) * 1000)
3394
+
3395
+ return int((time.time() - start) * 1000)
3396
+
2932
3397
  def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
2933
3398
  """Handle UserPromptSubmit hook - track when user sends messages"""
2934
- import time as time_module
3399
+ # Load instance data for coordination check and alias_announced
3400
+ instance_data = load_instance_position(instance_name)
3401
+ is_enabled = instance_data.get('enabled', False)
3402
+ last_stop = instance_data.get('last_stop', 0)
3403
+ alias_announced = instance_data.get('alias_announced', False)
2935
3404
 
2936
- # Update last user input timestamp
2937
- updates['last_user_input'] = time_module.time()
2938
- update_instance_position(instance_name, updates)
3405
+ # Coordinate with Stop hook only if enabled AND Stop hook is active
3406
+ stop_is_active = (time.time() - last_stop) < 1.0
2939
3407
 
2940
- # Signal any polling Stop hook to exit cleanly before user input processed
2941
- signal_file = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
2942
- try:
2943
- log_hook_error(f'userpromptsubmit:signal_file_touched_pid_{os.getpid()}')
2944
- signal_file.touch()
2945
- time_module.sleep(0.15) # Give Stop hook time to detect and exit
2946
- log_hook_error(f'userpromptsubmit:signal_file_unlinked_pid_{os.getpid()}')
2947
- signal_file.unlink()
2948
- except (OSError, PermissionError) as e:
2949
- log_hook_error(f'userpromptsubmit:signal_file_error', e)
2950
-
2951
- send_cmd = build_send_command('your message')
3408
+ if is_enabled and stop_is_active:
3409
+ updates['last_user_input'] = time.time()
3410
+ update_instance_position(instance_name, updates)
3411
+ wait_ms = wait_for_stop_exit(instance_name)
3412
+
3413
+ send_cmd = build_send_command('your message', instance_name)
2952
3414
  resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
3415
+
2953
3416
  # Build message based on what happened
2954
3417
  msg = None
2955
- if is_resume_match:
2956
- msg = f"[Resumed with hcom chat alias: {instance_name}. Use {send_cmd} to send messages]"
2957
- elif is_new_instance:
2958
- # Unmatched resume - show critical recovery message
2959
- msg = (
2960
- f"[HCOM RESUME DETECTED - CRITICAL ACTION REQUIRED "
2961
- f"You MUST recover your HCOM identity to maintain conversation context "
2962
- f"Run: {resume_cmd} "
2963
- f"This is REQUIRED for message history and position tracking]"
3418
+
3419
+ # Determine if this is an HCOM-launched instance
3420
+ is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
3421
+
3422
+ # PRIORITY 1: Handle unmatched resume FIRST (is_new_instance=True means SessionStart was skipped)
3423
+ # This must show critical recovery message regardless of HCOM_LAUNCHED status
3424
+ if is_new_instance:
3425
+ msg = build_hcom_bootstrap_text(instance_name)
3426
+ msg += (
3427
+ f"\n\n[CRITICAL: HCOM RESUME DETECTED - You MUST recover your identity to maintain conversation context. "
3428
+ f"Run this command: {resume_cmd} - This is REQUIRED for message history and position tracking]"
2964
3429
  )
2965
- else:
2966
- # Check if we need to announce alias (normal startup)
2967
- instance_data = load_instance_position(instance_name)
2968
- alias_announced = instance_data.get('alias_announced', False)
2969
- if not alias_announced:
2970
- msg = f"[Your hcom chat alias is {instance_name}. You can at-mention others in hcom chat by their alias to DM them. To send a message use: {send_cmd}]"
3430
+ update_instance_position(instance_name, {'alias_announced': True})
3431
+
3432
+ # PRIORITY 2: Normal startup - show bootstrap if not already announced
3433
+ elif not alias_announced:
3434
+ if is_hcom_launched:
3435
+ # HCOM-launched instance - show bootstrap immediately
3436
+ msg = build_hcom_bootstrap_text(instance_name)
2971
3437
  update_instance_position(instance_name, {'alias_announced': True})
3438
+ else:
3439
+ # Vanilla Claude instance - check if user is about to run an hcom command
3440
+ user_prompt = hook_data.get('prompt', '')
3441
+ hcom_command_pattern = r'\bhcom\s+\w+'
3442
+ if re.search(hcom_command_pattern, user_prompt, re.IGNORECASE):
3443
+ # Bootstrap not shown yet - show it preemptively before hcom command runs
3444
+ msg = "[HCOM COMMAND DETECTED]\n\n"
3445
+ msg += build_hcom_bootstrap_text(instance_name)
3446
+ update_instance_position(instance_name, {'alias_announced': True})
3447
+
3448
+ # PRIORITY 3: Add resume status note if we showed bootstrap for a matched resume
3449
+ if msg and is_resume_match and not is_new_instance:
3450
+ if is_enabled:
3451
+ msg += "\n[Session resumed. HCOM started for this instance - will receive chat messages. Your alias and conversation history preserved.]"
3452
+ else:
3453
+ msg += "\n[Session resumed. HCOM stopped for this instance - will not receive chat messages. Run 'hcom start' to rejoin chat. Your alias and conversation history preserved.]"
2972
3454
 
2973
3455
  if msg:
2974
- output = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": msg}}
2975
- print(json.dumps(output))
3456
+ output = {
3457
+ # "systemMessage": "HCOM enabled",
3458
+ "hookSpecificOutput": {
3459
+ "hookEventName": "UserPromptSubmit",
3460
+ "additionalContext": msg
3461
+ }
3462
+ }
3463
+ print(json.dumps(output), file=sys.stdout)
3464
+ # sys.exit(1)
2976
3465
 
2977
3466
  def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
2978
- """Handle SessionStart hook - deliver welcome/resume message"""
3467
+ """Handle SessionStart hook - minimal message, full details on first prompt"""
2979
3468
  source = hook_data.get('source', 'startup')
2980
3469
 
2981
- log_hook_error(f'sessionstart:is_resume_match_{is_resume_match}')
2982
- log_hook_error(f'sessionstart:instance_name_{instance_name}')
2983
- log_hook_error(f'sessionstart:source_{source}')
2984
- log_hook_error(f'sessionstart:updates_{updates}')
2985
- log_hook_error(f'sessionstart:hook_data_{hook_data}')
2986
-
2987
- # Reset alias_announced flag so alias shows again on resume/clear/compact
2988
- updates['alias_announced'] = False
2989
-
2990
- # Only update instance position if file exists (startup or matched resume)
2991
- # For unmatched resumes, skip - UserPromptSubmit will create the file with correct session_id
2992
- if source == 'startup' or is_resume_match:
3470
+ # Update instance if it exists (matched resume only, since we don't create in SessionStart anymore)
3471
+ instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3472
+ if instance_file.exists():
3473
+ updates['alias_announced'] = False
2993
3474
  update_instance_position(instance_name, updates)
2994
3475
  set_status(instance_name, 'session_start')
2995
3476
 
2996
- log_hook_error(f'sessionstart:instance_name_after_update_{instance_name}')
3477
+ # Minimal message - no alias yet (UserPromptSubmit will show full details)
3478
+ help_text = "[HCOM active. Submit a prompt to initialize.]"
2997
3479
 
2998
- # Build send command using helper
2999
- send_cmd = build_send_command('your message')
3000
- help_text = f"[Welcome! HCOM chat active. Send messages: {send_cmd}]"
3001
-
3002
- # Add subagent type if this is a named agent
3003
- subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
3004
- if subagent_type:
3005
- help_text += f" [Subagent: {subagent_type}]"
3006
-
3007
- # Add first use text only on startup
3008
- if source == 'startup':
3480
+ # Add first_use_text only for hcom-launched instances on startup
3481
+ if os.environ.get('HCOM_LAUNCHED') == '1' and source == 'startup':
3009
3482
  first_use_text = get_config_value('first_use_text', '')
3010
3483
  if first_use_text:
3011
3484
  help_text += f" [{first_use_text}]"
3012
- elif source == 'resume':
3013
- if is_resume_match:
3014
- help_text += f" [Resumed alias: {instance_name}]"
3015
- else:
3016
- help_text += f" [Session resumed]"
3017
3485
 
3018
- # Add instance hints to all messages
3019
- instance_hints = get_config_value('instance_hints', '')
3020
- if instance_hints:
3021
- help_text += f" [{instance_hints}]"
3022
-
3023
- # Output as additionalContext using hookSpecificOutput format
3024
3486
  output = {
3025
3487
  "hookSpecificOutput": {
3026
3488
  "hookEventName": "SessionStart",
3027
3489
  "additionalContext": help_text
3028
3490
  }
3029
3491
  }
3492
+
3030
3493
  print(json.dumps(output))
3031
3494
 
3495
+ def handle_sessionend(hook_data, instance_name, updates):
3496
+ """Handle SessionEnd hook - mark session as ended and set final status"""
3497
+ reason = hook_data.get('reason', 'unknown')
3498
+
3499
+ # Set session_ended flag to tell Stop hook to exit
3500
+ updates['session_ended'] = True
3501
+
3502
+ # Set status with reason as context (reason: clear, logout, prompt_input_exit, other)
3503
+ set_status(instance_name, 'session_ended', reason)
3504
+
3505
+ try:
3506
+ update_instance_position(instance_name, updates)
3507
+ except Exception as e:
3508
+ log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
3509
+
3032
3510
  def handle_hook(hook_type: str) -> None:
3033
3511
  """Unified hook handler for all HCOM hooks"""
3034
- if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
3035
- sys.exit(EXIT_SUCCESS)
3036
-
3037
3512
  hook_data = json.load(sys.stdin)
3038
- log_hook_error(f'handle_hook:hook_data_{hook_data}')
3039
3513
 
3040
- # DEBUG: Log which hook is being called with which session_id
3514
+ if not ensure_hcom_directories():
3515
+ log_hook_error('handle_hook', Exception('Failed to create directories'))
3516
+ sys.exit(EXIT_SUCCESS)
3517
+
3041
3518
  session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
3042
- log_hook_error(f'DEBUG: Hook {hook_type} called with session_id={session_id_short}')
3519
+ source_debug = hook_data.get('source', 'NO_SOURCE')
3520
+
3521
+ # Vanilla instance check (not hcom-launched)
3522
+ # Exit early if no instance file exists, except:
3523
+ # - PreToolUse (handles first send opt-in)
3524
+ # - UserPromptSubmit with hcom command (shows preemptive bootstrap)
3525
+ if hook_type != 'pre' and os.environ.get('HCOM_LAUNCHED') != '1':
3526
+ session_id = hook_data.get('session_id', '')
3527
+ if not session_id:
3528
+ sys.exit(EXIT_SUCCESS)
3529
+
3530
+ instance_name = get_display_name(session_id, os.environ.get('HCOM_PREFIX'))
3531
+ instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3532
+
3533
+ if not instance_file.exists():
3534
+ # Allow UserPromptSubmit through if prompt contains hcom command
3535
+ if hook_type == 'userpromptsubmit':
3536
+ user_prompt = hook_data.get('prompt', '')
3537
+ if not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE):
3538
+ sys.exit(EXIT_SUCCESS)
3539
+ # Continue - let handle_userpromptsubmit show bootstrap
3540
+ else:
3541
+ sys.exit(EXIT_SUCCESS)
3043
3542
 
3044
- instance_name, updates, _, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
3543
+ # Initialize instance context (creates file if needed, reuses existing if session_id matches)
3544
+ instance_name, updates, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
3545
+
3546
+ # Special bypass for unmatched resume - must show critical warning even if disabled
3547
+ # is_new_instance=True in UserPromptSubmit means SessionStart didn't create it (was skipped)
3548
+ if hook_type == 'userpromptsubmit' and is_new_instance:
3549
+ handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
3550
+ sys.exit(EXIT_SUCCESS)
3551
+
3552
+ # Check enabled status (PreToolUse handles toggle, so exempt)
3553
+ if hook_type != 'pre':
3554
+ instance_data = load_instance_position(instance_name)
3555
+ if not instance_data.get('enabled', False):
3556
+ sys.exit(EXIT_SUCCESS)
3045
3557
 
3046
3558
  match hook_type:
3047
3559
  case 'pre':
3048
3560
  handle_pretooluse(hook_data, instance_name, updates)
3049
- case 'stop':
3561
+ case 'poll':
3050
3562
  handle_stop(hook_data, instance_name, updates)
3051
3563
  case 'notify':
3052
3564
  handle_notify(hook_data, instance_name, updates)
@@ -3054,9 +3566,8 @@ def handle_hook(hook_type: str) -> None:
3054
3566
  handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
3055
3567
  case 'sessionstart':
3056
3568
  handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
3057
-
3058
- log_hook_error(f'handle_hook:instance_name_{instance_name}')
3059
-
3569
+ case 'sessionend':
3570
+ handle_sessionend(hook_data, instance_name, updates)
3060
3571
 
3061
3572
  sys.exit(EXIT_SUCCESS)
3062
3573
 
@@ -3066,48 +3577,71 @@ def handle_hook(hook_type: str) -> None:
3066
3577
  def main(argv=None):
3067
3578
  """Main command dispatcher"""
3068
3579
  if argv is None:
3069
- argv = sys.argv
3070
-
3071
- if len(argv) < 2:
3580
+ argv = sys.argv[1:]
3581
+ else:
3582
+ argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
3583
+
3584
+ # Check for help
3585
+ if needs_help(argv):
3072
3586
  return cmd_help()
3073
-
3074
- cmd = argv[1]
3075
-
3076
- match cmd:
3077
- case 'help' | '--help':
3078
- return cmd_help()
3079
- case 'open':
3080
- return cmd_open(*argv[2:])
3081
- case 'watch':
3082
- return cmd_watch(*argv[2:])
3083
- case 'clear':
3084
- return cmd_clear()
3085
- case 'cleanup':
3086
- return cmd_cleanup(*argv[2:])
3087
- case 'send':
3088
- if len(argv) < 3:
3089
- print(format_error("Message required"), file=sys.stderr)
3090
- return 1
3091
-
3092
- # HIDDEN COMMAND: --resume is only used internally by instances during resume workflow
3093
- # Not meant for regular CLI usage. Primary usage:
3094
- # - From instances: $HCOM send "message" (instances send messages to each other)
3095
- # - From CLI: hcom send "message" (user/claude orchestrator sends to instances)
3096
- if argv[2] == '--resume':
3097
- if len(argv) < 4:
3098
- print(format_error("Alias required for --resume"), file=sys.stderr)
3099
- return 1
3100
- return cmd_resume_merge(argv[3])
3101
-
3102
- return cmd_send(argv[2])
3103
- case 'kill':
3104
- return cmd_kill(*argv[2:])
3105
- case 'stop' | 'notify' | 'pre' | 'sessionstart' | 'userpromptsubmit':
3106
- handle_hook(cmd)
3107
- return 0
3108
- case _:
3109
- print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
3587
+
3588
+ # Handle hook commands (special case - no parsing needed)
3589
+ if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
3590
+ handle_hook(argv[0])
3591
+ return 0
3592
+
3593
+ # Handle send_cli (hidden command)
3594
+ if argv and argv[0] == 'send_cli':
3595
+ if len(argv) < 2:
3596
+ print(format_error("Message required"), file=sys.stderr)
3597
+ return 1
3598
+ return cmd_send_cli(argv[1])
3599
+
3600
+ # Split on -- separator for forwarding args
3601
+ hcom_args, forwarded = split_forwarded_args(argv)
3602
+
3603
+ # Ensure directories exist for commands that need them
3604
+ if hcom_args and hcom_args[0] not in ('help', '--help', '-h'):
3605
+ if not ensure_hcom_directories():
3606
+ print(format_error("Failed to create HCOM directories"), file=sys.stderr)
3110
3607
  return 1
3111
3608
 
3609
+ # Build parser and parse arguments
3610
+ parser = build_parser()
3611
+
3612
+ try:
3613
+ namespace = parser.parse_args(hcom_args)
3614
+ except SystemExit as exc:
3615
+ if exc.code != 0:
3616
+ print("Run 'hcom -h' for help", file=sys.stderr)
3617
+ return exc.code if isinstance(exc.code, int) else 1
3618
+
3619
+ # Dispatch to command parsers and get typed command objects
3620
+ try:
3621
+ command_obj = dispatch(namespace, forwarded)
3622
+
3623
+ # command_obj could be exit code (from help) or command dataclass
3624
+ if isinstance(command_obj, int):
3625
+ return command_obj
3626
+
3627
+ # Execute the command with typed object
3628
+ if isinstance(command_obj, OpenCommand):
3629
+ return cmd_open(command_obj)
3630
+ elif isinstance(command_obj, WatchCommand):
3631
+ return cmd_watch(command_obj)
3632
+ elif isinstance(command_obj, StopCommand):
3633
+ return cmd_stop(command_obj)
3634
+ elif isinstance(command_obj, StartCommand):
3635
+ return cmd_start(command_obj)
3636
+ elif isinstance(command_obj, SendCommand):
3637
+ return cmd_send(command_obj)
3638
+ else:
3639
+ print(format_error(f"Unknown command type: {type(command_obj)}"), file=sys.stderr)
3640
+ return 1
3641
+
3642
+ except CLIError as exc:
3643
+ print(str(exc), file=sys.stderr)
3644
+ return 1
3645
+
3112
3646
  if __name__ == '__main__':
3113
3647
  sys.exit(main())