hcom 0.5.0__py3-none-any.whl → 0.6.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,3684 +1,4 @@
1
- #!/usr/bin/env python3
2
- """
3
- hcom
4
- CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
5
- """
1
+ from .cli import main
6
2
 
7
- import os
8
- import sys
9
- import json
10
- import io
11
- import tempfile
12
- import shutil
13
- import shlex
14
- import re
15
- import subprocess
16
- import time
17
- import select
18
- import platform
19
- import random
20
- from pathlib import Path
21
- from datetime import datetime, timedelta
22
- from typing import Any, Callable, NamedTuple, TextIO
23
- from dataclasses import dataclass
24
-
25
- if sys.version_info < (3, 10):
26
- sys.exit("Error: hcom requires Python 3.10 or higher")
27
-
28
- __version__ = "0.5.0"
29
-
30
- # ==================== Constants ====================
31
-
32
- IS_WINDOWS = sys.platform == 'win32'
33
-
34
- def is_wsl() -> bool:
35
- """Detect if running in WSL"""
36
- if platform.system() != 'Linux':
37
- return False
38
- try:
39
- with open('/proc/version', 'r') as f:
40
- return 'microsoft' in f.read().lower()
41
- except (FileNotFoundError, PermissionError, OSError):
42
- return False
43
-
44
- def is_termux() -> bool:
45
- """Detect if running in Termux on Android"""
46
- return (
47
- 'TERMUX_VERSION' in os.environ or # Primary: Works all versions
48
- 'TERMUX__ROOTFS' in os.environ or # Modern: v0.119.0+
49
- Path('/data/data/com.termux').exists() or # Fallback: Path check
50
- 'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
51
- )
52
-
53
-
54
- # Windows API constants
55
- CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
56
-
57
- # Timing constants
58
- FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
59
- STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
60
-
61
- MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
62
- AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
63
- TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
64
-
65
- RESET = "\033[0m"
66
- DIM = "\033[2m"
67
- BOLD = "\033[1m"
68
- FG_GREEN = "\033[32m"
69
- FG_CYAN = "\033[36m"
70
- FG_WHITE = "\033[37m"
71
- FG_BLACK = "\033[30m"
72
- BG_BLUE = "\033[44m"
73
- BG_GREEN = "\033[42m"
74
- BG_CYAN = "\033[46m"
75
- BG_YELLOW = "\033[43m"
76
- BG_RED = "\033[41m"
77
- BG_GRAY = "\033[100m"
78
-
79
- STATUS_MAP = {
80
- "waiting": (BG_BLUE, "◉"),
81
- "delivered": (BG_CYAN, "▷"),
82
- "active": (BG_GREEN, "▶"),
83
- "blocked": (BG_YELLOW, "■"),
84
- "inactive": (BG_RED, "○"),
85
- "unknown": (BG_GRAY, "○")
86
- }
87
-
88
- # Map status events to (display_category, description_template)
89
- STATUS_INFO = {
90
- 'session_start': ('active', 'started'),
91
- 'tool_pending': ('active', '{} executing'),
92
- 'waiting': ('waiting', 'idle'),
93
- 'message_delivered': ('delivered', 'msg from {}'),
94
- 'timeout': ('inactive', 'timeout'),
95
- 'stopped': ('inactive', 'stopped'),
96
- 'force_stopped': ('inactive', 'force stopped'),
97
- 'started': ('active', 'starting'),
98
- 'session_ended': ('inactive', 'ended: {}'),
99
- 'blocked': ('blocked', '{} blocked'),
100
- 'unknown': ('unknown', 'unknown'),
101
- }
102
-
103
- # ==================== Windows/WSL Console Unicode ====================
104
-
105
- # Apply UTF-8 encoding for Windows and WSL
106
- if IS_WINDOWS or is_wsl():
107
- try:
108
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
109
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
110
- except (AttributeError, OSError):
111
- pass # Fallback if stream redirection fails
112
-
113
- # ==================== Error Handling Strategy ====================
114
- # Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
115
- # CLI: Can raise exceptions for user feedback. Check return values.
116
- # Critical I/O: atomic_write, save_instance_position
117
- # Pattern: Try/except/return False in hooks, raise in CLI operations.
118
-
119
- # ==================== CLI Errors ====================
120
-
121
- class CLIError(Exception):
122
- """Raised when arguments cannot be mapped to command semantics."""
123
-
124
- # ==================== Help Text ====================
125
-
126
- HELP_TEXT = """hcom 0.5.0
127
-
128
- Usage: [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
129
- hcom watch [--logs|--status|--wait [SEC]]
130
- hcom send "message"
131
- hcom stop [alias|all] [--force]
132
- hcom start [alias]
133
- hcom reset [logs|hooks|config]
134
-
135
- Launch Examples:
136
- hcom 3 Open 3 terminals with claude connected to hcom
137
- hcom 3 claude -p + Background/headless
138
- HCOM_TAG=api hcom 3 claude -p + @-mention group tag
139
- claude 'run hcom start' claude code with prompt will also work
140
-
141
- Commands:
142
- watch Interactive messaging/status dashboard
143
- --logs Print all messages
144
- --status Print instance status JSON
145
- --wait [SEC] Wait and notify for new message
146
-
147
- send "msg" Send message to all instances
148
- send "@alias msg" Send to specific instance/group
149
-
150
- stop Stop current instance (from inside Claude)
151
- stop <alias> Stop specific instance
152
- stop all Stop all instances
153
- --force Emergency stop (denies Bash tool)
154
-
155
- start Start current instance (from inside Claude)
156
- start <alias> Start specific instance
157
-
158
- reset Stop all + archive logs + remove hooks + clear config
159
- reset logs Clear + archive conversation log
160
- reset hooks Safely remove hcom hooks from claude settings.json
161
- reset config Clear + backup config.env
162
-
163
- Environment Variables:
164
- HCOM_TAG=name Group tag (creates name-* instances)
165
- HCOM_AGENT=type Agent type (comma-separated for multiple)
166
- HCOM_TERMINAL=mode Terminal: new|here|print|"custom {script}"
167
- HCOM_PROMPT=text "Say hi in hcom chat" (default)
168
- HCOM_HINTS=text Text appended to all messages received by instance
169
- HCOM_TIMEOUT=secs Time until disconnected from hcom chat (default 1800s / 30mins)
170
-
171
- Config: ~/.hcom/config.env
172
- Docs: https://github.com/aannoo/claude-hook-comms"""
173
-
174
-
175
- # ==================== Logging ====================
176
-
177
- def log_hook_error(hook_name: str, error: Exception | str | None = None) -> None:
178
- """Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
179
- import traceback
180
- try:
181
- log_file = hcom_path(SCRIPTS_DIR) / "hooks.log"
182
- timestamp = datetime.now().isoformat()
183
- if error and isinstance(error, Exception):
184
- tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__))
185
- with open(log_file, 'a') as f:
186
- f.write(f"{timestamp}|{hook_name}|{type(error).__name__}: {error}\n{tb}\n")
187
- else:
188
- with open(log_file, 'a') as f:
189
- f.write(f"{timestamp}|{hook_name}|{error or 'checkpoint'}\n")
190
- except (OSError, PermissionError):
191
- pass # Silent failure in error logging
192
-
193
- # ==================== Config Defaults ====================
194
- # Config precedence: env var > ~/.hcom/config.env > defaults
195
- # All config via HcomConfig dataclass (timeout, terminal, prompt, hints, tag, agent)
196
-
197
- # Constants (not configurable)
198
- MAX_MESSAGE_SIZE = 1048576 # 1MB
199
- MAX_MESSAGES_PER_DELIVERY = 50
200
- SENDER = 'bigboss'
201
- SENDER_EMOJI = '🐳'
202
- SKIP_HISTORY = True # New instances start at current log position (skip old messages)
203
-
204
- # Path constants
205
- LOG_FILE = "hcom.log"
206
- INSTANCES_DIR = "instances"
207
- LOGS_DIR = ".tmp/logs"
208
- SCRIPTS_DIR = ".tmp/scripts"
209
- FLAGS_DIR = ".tmp/flags"
210
- CONFIG_FILE = "config.env"
211
- ARCHIVE_DIR = "archive"
212
-
213
- # Hook configuration - single source of truth for setup_hooks() and verify_hooks_installed()
214
- # Format: (hook_type, matcher, command_suffix, timeout)
215
- # Command gets built as: hook_cmd_base + ' ' + command_suffix (e.g., '${HCOM} poll')
216
- HOOK_CONFIGS = [
217
- ('SessionStart', '', 'sessionstart', None),
218
- ('UserPromptSubmit', '', 'userpromptsubmit', None),
219
- ('PreToolUse', 'Bash', 'pre', None),
220
- ('PostToolUse', 'Bash', 'post', None), # Match Bash only
221
- ('Stop', '', 'poll', 86400), # Poll for messages (24hr max timeout)
222
- ('Notification', '', 'notify', None),
223
- ('SessionEnd', '', 'sessionend', None),
224
- ]
225
-
226
- # Derived from HOOK_CONFIGS - guaranteed to stay in sync
227
- ACTIVE_HOOK_TYPES = [cfg[0] for cfg in HOOK_CONFIGS]
228
- HOOK_COMMANDS = [cfg[2] for cfg in HOOK_CONFIGS]
229
- LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES
230
- LEGACY_HOOK_COMMANDS = HOOK_COMMANDS
231
-
232
- # Hook removal patterns - used by _remove_hcom_hooks_from_settings()
233
- # Dynamically build from LEGACY_HOOK_COMMANDS to match current and legacy hook formats
234
- _HOOK_ARGS_PATTERN = '|'.join(LEGACY_HOOK_COMMANDS)
235
- HCOM_HOOK_PATTERNS = [
236
- re.compile(r'\$\{?HCOM'), # Current: Environment variable ${HCOM:-...}
237
- re.compile(r'\bHCOM_ACTIVE.*hcom\.py'), # LEGACY: Unix HCOM_ACTIVE conditional
238
- re.compile(r'IF\s+"%HCOM_ACTIVE%"'), # LEGACY: Windows HCOM_ACTIVE conditional
239
- re.compile(rf'\bhcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: Direct hcom command
240
- re.compile(rf'\buvx\s+hcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: uvx hcom command
241
- re.compile(rf'hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: hcom.py with optional quote
242
- re.compile(rf'["\'][^"\']*hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b(?=\s|$)'), # LEGACY: Quoted path
243
- re.compile(r'sh\s+-c.*hcom'), # LEGACY: Shell wrapper
244
- ]
245
-
246
- # PreToolUse hook pattern - matches hcom commands for session_id injection and auto-approval
247
- # - hcom send (any args)
248
- # - hcom stop (no args) | hcom start (no args)
249
- # - hcom help | hcom --help | hcom -h
250
- # - hcom watch --status | hcom watch --launch | hcom watch --logs | hcom watch --wait
251
- # Negative lookahead (?!\s+[-\w]) ensures stop/start not followed by arguments or flags
252
- HCOM_COMMAND_PATTERN = re.compile(
253
- r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+'
254
- r'(?:send\b|(?:stop|start)(?!\s+[-\w])|(?:help|--help|-h)\b|watch\s+(?:--status|--launch|--logs|--wait)\b)'
255
- )
256
-
257
- # ==================== File System Utilities ====================
258
-
259
- def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
260
- """Build path under ~/.hcom"""
261
- path = Path.home() / ".hcom"
262
- if parts:
263
- path = path.joinpath(*parts)
264
- if ensure_parent:
265
- path.parent.mkdir(parents=True, exist_ok=True)
266
- return path
267
-
268
- def ensure_hcom_directories() -> bool:
269
- """Ensure all critical HCOM directories exist. Idempotent, safe to call repeatedly.
270
- Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
271
- Returns True on success, False on failure."""
272
- try:
273
- for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, FLAGS_DIR, ARCHIVE_DIR]:
274
- hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
275
- return True
276
- except (OSError, PermissionError):
277
- return False
278
-
279
- def atomic_write(filepath: str | Path, content: str) -> bool:
280
- """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."""
281
- filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
282
- filepath.parent.mkdir(parents=True, exist_ok=True)
283
-
284
- for attempt in range(3):
285
- with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
286
- tmp.write(content)
287
- tmp.flush()
288
- os.fsync(tmp.fileno())
289
-
290
- try:
291
- os.replace(tmp.name, filepath)
292
- return True
293
- except PermissionError:
294
- if IS_WINDOWS and attempt < 2:
295
- time.sleep(FILE_RETRY_DELAY)
296
- continue
297
- else:
298
- try: # Clean up temp file on final failure
299
- Path(tmp.name).unlink()
300
- except (FileNotFoundError, PermissionError, OSError):
301
- pass
302
- return False
303
- except Exception:
304
- try: # Clean up temp file on any other error
305
- os.unlink(tmp.name)
306
- except (FileNotFoundError, PermissionError, OSError):
307
- pass
308
- return False
309
-
310
- return False # All attempts exhausted
311
-
312
- def read_file_with_retry(filepath: str | Path, read_func: Callable[[TextIO], Any], default: Any = None, max_retries: int = 3) -> Any:
313
- """Read file with retry logic for Windows file locking"""
314
- if not Path(filepath).exists():
315
- return default
316
-
317
- for attempt in range(max_retries):
318
- try:
319
- with open(filepath, 'r', encoding='utf-8') as f:
320
- return read_func(f)
321
- except PermissionError:
322
- # Only retry on Windows (file locking issue)
323
- if IS_WINDOWS and attempt < max_retries - 1:
324
- time.sleep(FILE_RETRY_DELAY)
325
- else:
326
- # Re-raise on Unix or after max retries on Windows
327
- if not IS_WINDOWS:
328
- raise # Unix permission errors are real issues
329
- break # Windows: return default after retries
330
- except (json.JSONDecodeError, FileNotFoundError, IOError):
331
- break # Don't retry on other errors
332
-
333
- return default
334
-
335
- def get_instance_file(instance_name: str) -> Path:
336
- """Get path to instance's position file with path traversal protection"""
337
- # Sanitize instance name to prevent directory traversal
338
- if not instance_name:
339
- instance_name = "unknown"
340
- safe_name = instance_name.replace('..', '').replace('/', '-').replace('\\', '-').replace(os.sep, '-')
341
- if not safe_name:
342
- safe_name = "unknown"
343
-
344
- return hcom_path(INSTANCES_DIR, f"{safe_name}.json")
345
-
346
- def load_instance_position(instance_name: str) -> dict[str, Any]:
347
- """Load position data for a single instance"""
348
- instance_file = get_instance_file(instance_name)
349
-
350
- data = read_file_with_retry(
351
- instance_file,
352
- lambda f: json.load(f),
353
- default={}
354
- )
355
-
356
- return data
357
-
358
- def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
359
- """Save position data for a single instance. Returns True on success, False on failure."""
360
- try:
361
- instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json")
362
- return atomic_write(instance_file, json.dumps(data, indent=2))
363
- except (OSError, PermissionError, ValueError):
364
- return False
365
-
366
- def get_claude_settings_path() -> Path:
367
- """Get path to global Claude settings file"""
368
- return Path.home() / '.claude' / 'settings.json'
369
-
370
- def load_settings_json(settings_path: Path, default: Any = None) -> dict[str, Any] | None:
371
- """Load and parse settings JSON file with retry logic"""
372
- return read_file_with_retry(
373
- settings_path,
374
- lambda f: json.load(f),
375
- default=default
376
- )
377
-
378
- def load_all_positions() -> dict[str, dict[str, Any]]:
379
- """Load positions from all instance files"""
380
- instances_dir = hcom_path(INSTANCES_DIR)
381
- if not instances_dir.exists():
382
- return {}
383
-
384
- positions = {}
385
- for instance_file in instances_dir.glob("*.json"):
386
- instance_name = instance_file.stem
387
- data = read_file_with_retry(
388
- instance_file,
389
- lambda f: json.load(f),
390
- default={}
391
- )
392
- if data:
393
- positions[instance_name] = data
394
- return positions
395
-
396
- def clear_all_positions() -> None:
397
- """Clear all instance position files and related mapping files"""
398
- instances_dir = hcom_path(INSTANCES_DIR)
399
- if instances_dir.exists():
400
- for f in instances_dir.glob('*.json'):
401
- f.unlink()
402
-
403
- # ==================== Configuration System ====================
404
-
405
- @dataclass
406
- class HcomConfig:
407
- """HCOM configuration with validation. Load priority: env → file → defaults"""
408
- timeout: int = 1800
409
- terminal: str = 'new'
410
- prompt: str = 'say hi in hcom chat'
411
- hints: str = ''
412
- tag: str = ''
413
- agent: str = ''
414
-
415
- def __post_init__(self):
416
- """Validate configuration on construction"""
417
- errors = self.validate()
418
- if errors:
419
- raise ValueError(f"Invalid config:\n" + "\n".join(f" - {e}" for e in errors))
420
-
421
- def validate(self) -> list[str]:
422
- """Validate all fields, return list of errors"""
423
- errors = []
424
-
425
- # Validate timeout
426
- # Validate timeout (bool is subclass of int in Python, must check explicitly)
427
- if isinstance(self.timeout, bool):
428
- errors.append(f"timeout must be an integer, not boolean (got {self.timeout})")
429
- elif not isinstance(self.timeout, int):
430
- errors.append(f"timeout must be an integer, got {type(self.timeout).__name__}")
431
- elif not 1 <= self.timeout <= 86400:
432
- errors.append(f"timeout must be 1-86400 seconds (24 hours), got {self.timeout}")
433
-
434
- # Validate terminal
435
- if not isinstance(self.terminal, str):
436
- errors.append(f"terminal must be a string, got {type(self.terminal).__name__}")
437
- else:
438
- valid_modes = ('new', 'here', 'print')
439
- if self.terminal not in valid_modes and '{script}' not in self.terminal:
440
- errors.append(
441
- f"terminal must be one of {valid_modes} or custom command with {{script}}, "
442
- f"got '{self.terminal}'"
443
- )
444
-
445
- # Validate tag (only alphanumeric and hyphens - security: prevent log delimiter injection)
446
- if not isinstance(self.tag, str):
447
- errors.append(f"tag must be a string, got {type(self.tag).__name__}")
448
- elif self.tag and not re.match(r'^[a-zA-Z0-9-]+$', self.tag):
449
- errors.append("tag can only contain letters, numbers, and hyphens")
450
-
451
- # Validate agent
452
- if not isinstance(self.agent, str):
453
- errors.append(f"agent must be a string, got {type(self.agent).__name__}")
454
-
455
- return errors
456
-
457
- @classmethod
458
- def load(cls) -> 'HcomConfig':
459
- """Load config with precedence: env var → file → defaults"""
460
- # Ensure config file exists
461
- config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
462
- created_config = False
463
- if not config_path.exists():
464
- _write_default_config(config_path)
465
- created_config = True
466
-
467
- # Warn once if legacy config.json still exists when creating config.env
468
- legacy_config = hcom_path('config.json')
469
- if created_config and legacy_config.exists():
470
- print(
471
- format_error(
472
- "Found legacy ~/.hcom/config.json; new config file is: ~/.hcom/config.env."
473
- ),
474
- file=sys.stderr,
475
- )
476
-
477
- # Parse config file once
478
- file_config = _parse_env_file(config_path) if config_path.exists() else {}
479
-
480
- def get_var(key: str) -> str | None:
481
- """Get variable with precedence: env → file"""
482
- if key in os.environ:
483
- return os.environ[key]
484
- if key in file_config:
485
- return file_config[key]
486
- return None
487
-
488
- data = {}
489
-
490
- # Load timeout (requires int conversion)
491
- timeout_str = get_var('HCOM_TIMEOUT')
492
- if timeout_str is not None:
493
- try:
494
- data['timeout'] = int(timeout_str)
495
- except (ValueError, TypeError):
496
- pass # Use default
497
-
498
- # Load string values
499
- terminal = get_var('HCOM_TERMINAL')
500
- if terminal is not None:
501
- data['terminal'] = terminal
502
- prompt = get_var('HCOM_PROMPT')
503
- if prompt is not None:
504
- data['prompt'] = prompt
505
- hints = get_var('HCOM_HINTS')
506
- if hints is not None:
507
- data['hints'] = hints
508
- tag = get_var('HCOM_TAG')
509
- if tag is not None:
510
- data['tag'] = tag
511
- agent = get_var('HCOM_AGENT')
512
- if agent is not None:
513
- data['agent'] = agent
514
-
515
- return cls(**data) # Validation happens in __post_init__
516
-
517
- def _parse_env_file(config_path: Path) -> dict[str, str]:
518
- """Parse ENV file (KEY=VALUE format) with security validation"""
519
- config = {}
520
-
521
- # Dangerous shell metacharacters that enable command injection
522
- DANGEROUS_CHARS = ['`', '$', ';', '|', '&', '\n', '\r']
523
-
524
- try:
525
- content = config_path.read_text(encoding='utf-8')
526
- for line in content.splitlines():
527
- line = line.strip()
528
- if not line or line.startswith('#'):
529
- continue
530
- if '=' in line:
531
- key, _, value = line.partition('=')
532
- key = key.strip()
533
- value = value.strip()
534
-
535
- # Security: Validate HCOM_TERMINAL for command injection
536
- if key == 'HCOM_TERMINAL':
537
- if any(c in value for c in DANGEROUS_CHARS):
538
- print(
539
- f"Warning: Unsafe characters in HCOM_TERMINAL "
540
- f"({', '.join(repr(c) for c in DANGEROUS_CHARS if c in value)}), "
541
- f"ignoring custom terminal command",
542
- file=sys.stderr
543
- )
544
- continue
545
- # Additional check: custom commands must contain {script} placeholder
546
- if value not in ('new', 'here', 'print') and '{script}' not in value:
547
- print(
548
- f"Warning: HCOM_TERMINAL custom command must include {{script}} placeholder, "
549
- f"ignoring",
550
- file=sys.stderr
551
- )
552
- continue
553
-
554
- # Remove outer quotes only if they match
555
- if len(value) >= 2:
556
- if (value[0] == value[-1]) and value[0] in ('"', "'"):
557
- value = value[1:-1]
558
- if key:
559
- config[key] = value
560
- except (FileNotFoundError, PermissionError, UnicodeDecodeError):
561
- pass
562
- return config
563
-
564
- def _write_default_config(config_path: Path) -> None:
565
- """Write default config file with documentation"""
566
- header = """# HCOM Configuration
567
- #
568
- # All HCOM_* settings (and any env var ie. Claude Code settings)
569
- # can be set here or via environment variables.
570
- # Environment variables override config file values.
571
- #
572
- # HCOM settings:
573
- # HCOM_TIMEOUT - Instance Stop hook wait timeout in seconds (default: 1800)
574
- # HCOM_TERMINAL - Terminal mode: "new", "here", "print", or custom command with {script}
575
- # HCOM_PROMPT - Initial prompt for new instances (empty = no auto prompt)
576
- # HCOM_HINTS - Text appended to all messages received by instances
577
- # HCOM_TAG - Group tag for instances (creates tag-* instances)
578
- # HCOM_AGENT - Claude code subagent from .claude/agents/, comma-separated for multiple
579
- #
580
- # Put each value on separate lines without comments.
581
- #
582
- #
583
- """
584
- defaults = [
585
- 'HCOM_TIMEOUT=1800',
586
- 'HCOM_TERMINAL=new',
587
- 'HCOM_PROMPT=say hi in hcom chat',
588
- 'HCOM_HINTS=',
589
- 'HCOM_TAG=',
590
- 'HCOM_AGENT=',
591
- ]
592
- try:
593
- atomic_write(config_path, header + '\n'.join(defaults) + '\n')
594
- except Exception:
595
- pass
596
-
597
- # Global config instance (cached)
598
- _config: HcomConfig | None = None
599
-
600
- def get_config() -> HcomConfig:
601
- """Get cached config, loading if needed"""
602
- global _config
603
- if _config is None:
604
- _config = HcomConfig.load()
605
- return _config
606
-
607
- def _build_quoted_invocation() -> str:
608
- """Build properly quoted python + script path for current platform"""
609
- python_path = sys.executable
610
- script_path = str(Path(__file__).resolve())
611
-
612
- if IS_WINDOWS:
613
- if ' ' in python_path or ' ' in script_path:
614
- return f'"{python_path}" "{script_path}"'
615
- return f'{python_path} {script_path}'
616
- else:
617
- return f'{shlex.quote(python_path)} {shlex.quote(script_path)}'
618
-
619
- def get_hook_command() -> tuple[str, dict[str, Any]]:
620
- """Get hook command - hooks always run, Python code gates participation
621
-
622
- Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
623
- Participation is controlled by enabled flag in instance JSON files.
624
- """
625
- if IS_WINDOWS:
626
- # Windows: use python path directly
627
- return _build_quoted_invocation(), {}
628
- else:
629
- # Unix: Use HCOM env var from settings.json
630
- return '${HCOM}', {}
631
-
632
- def _detect_hcom_command_type() -> str:
633
- """Detect how to invoke hcom based on execution context
634
- Priority:
635
- 1. uvx - If running in uv-managed Python and uvx available
636
- (works for both temporary uvx runs and permanent uv tool install)
637
- 2. short - If hcom binary in PATH
638
- 3. full - Fallback to full python invocation
639
- """
640
- if 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
641
- return 'uvx'
642
- elif shutil.which('hcom'):
643
- return 'short'
644
- else:
645
- return 'full'
646
-
647
- def _parse_version(v: str) -> tuple:
648
- """Parse version string to comparable tuple"""
649
- return tuple(int(x) for x in v.split('.') if x.isdigit())
650
-
651
- def get_update_notice() -> str | None:
652
- """Check PyPI for updates (once daily), return message if available"""
653
- flag = hcom_path(FLAGS_DIR, 'update_available')
654
-
655
- # Check PyPI if flag missing or >24hrs old
656
- should_check = not flag.exists() or time.time() - flag.stat().st_mtime > 86400
657
-
658
- if should_check:
659
- try:
660
- import urllib.request
661
- with urllib.request.urlopen('https://pypi.org/pypi/hcom/json', timeout=2) as f:
662
- latest = json.load(f)['info']['version']
663
-
664
- if _parse_version(latest) > _parse_version(__version__):
665
- atomic_write(flag, latest) # mtime = cache timestamp
666
- else:
667
- flag.unlink(missing_ok=True)
668
- return None
669
- except Exception:
670
- pass # Network error, use cached value if exists
671
-
672
- # Return message if update available
673
- if not flag.exists():
674
- return None
675
-
676
- try:
677
- latest = flag.read_text().strip()
678
- # Double-check version (handles manual upgrades)
679
- if _parse_version(__version__) >= _parse_version(latest):
680
- flag.unlink(missing_ok=True)
681
- return None
682
-
683
- cmd = "uv tool upgrade hcom" if _detect_hcom_command_type() == 'uvx' else "pip install -U hcom"
684
- return f"→ hcom v{latest} available: {cmd}"
685
- except Exception:
686
- return None
687
-
688
- def _build_hcom_env_value() -> str:
689
- """Build the value for settings['env']['HCOM'] based on current execution context
690
- Uses build_hcom_command() without caching for fresh detection on every call.
691
- """
692
- return build_hcom_command(None)
693
-
694
- def build_hcom_command(instance_name: str | None = None) -> str:
695
- """Build base hcom command - caches PATH check in instance file on first use"""
696
- # Determine command type (cached or detect)
697
- cmd_type = None
698
- if instance_name:
699
- data = load_instance_position(instance_name)
700
- if data.get('session_id'):
701
- if 'hcom_cmd_type' not in data:
702
- cmd_type = _detect_hcom_command_type()
703
- data['hcom_cmd_type'] = cmd_type
704
- save_instance_position(instance_name, data)
705
- else:
706
- cmd_type = data.get('hcom_cmd_type')
707
-
708
- if not cmd_type:
709
- cmd_type = _detect_hcom_command_type()
710
-
711
- # Build command based on type
712
- if cmd_type == 'short':
713
- return 'hcom'
714
- elif cmd_type == 'uvx':
715
- return 'uvx hcom'
716
- else:
717
- # Full path fallback
718
- return _build_quoted_invocation()
719
-
720
- def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
721
- """Build send command - caches PATH check in instance file on first use"""
722
- msg = f" '{example_msg}'" if example_msg else ''
723
- base_cmd = build_hcom_command(instance_name)
724
- return f'{base_cmd} send{msg}'
725
-
726
- def build_claude_env() -> dict[str, str]:
727
- """Build environment variables for Claude instances
728
-
729
- Passes current environment to Claude, with config.env providing defaults.
730
- HCOM_* variables are filtered out (consumed by hcom, not passed to Claude).
731
- """
732
- env = {}
733
-
734
- # Read config file directly for Claude Code env vars (non-HCOM_ keys)
735
- config_path = hcom_path(CONFIG_FILE)
736
- if config_path.exists():
737
- file_config = _parse_env_file(config_path)
738
- for key, value in file_config.items():
739
- if not key.startswith('HCOM_'):
740
- env[key] = str(value)
741
-
742
- # Overlay with current environment (except HCOM_*)
743
- # This ensures user's shell environment is respected
744
- for key, value in os.environ.items():
745
- if not key.startswith('HCOM_'):
746
- env[key] = value
747
-
748
- return env
749
-
750
- # ==================== Message System ====================
751
-
752
- def validate_message(message: str) -> str | None:
753
- """Validate message size and content. Returns error message or None if valid."""
754
- if not message or not message.strip():
755
- return format_error("Message required")
756
-
757
- # Reject control characters (except \n, \r, \t)
758
- if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
759
- return format_error("Message contains control characters")
760
-
761
- if len(message) > MAX_MESSAGE_SIZE:
762
- return format_error(f"Message too large (max {MAX_MESSAGE_SIZE} chars)")
763
-
764
- return None
765
-
766
- def send_message(from_instance: str, message: str) -> bool:
767
- """Send a message to the log"""
768
- try:
769
- log_file = hcom_path(LOG_FILE)
770
-
771
- escaped_message = message.replace('|', '\\|')
772
- escaped_from = from_instance.replace('|', '\\|')
773
-
774
- timestamp = datetime.now().isoformat()
775
- line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
776
-
777
- with open(log_file, 'a', encoding='utf-8') as f:
778
- f.write(line)
779
- f.flush()
780
-
781
- return True
782
- except Exception:
783
- return False
784
-
785
- def build_hcom_bootstrap_text(instance_name: str) -> str:
786
- """Build comprehensive HCOM bootstrap context for instances"""
787
- hcom_cmd = build_hcom_command(instance_name=instance_name)
788
-
789
- # Add command override notice if not using short form
790
- command_notice = ""
791
- if hcom_cmd != "hcom":
792
- command_notice = f"""IMPORTANT:
793
- The hcom command in this environment is: {hcom_cmd}
794
- Replace all mentions of "hcom" below with this command.
795
-
796
- """
797
-
798
- # Add tag-specific notice if instance is tagged
799
- tag = get_config().tag
800
- tag_notice = ""
801
- if tag:
802
- tag_notice = f"""
803
- GROUP TAG: You are in the '{tag}' group.
804
- - To message your group: hcom send "@{tag} your message"
805
- - Group messages are targeted - only instances with an alias starting with {tag}-* receive them
806
- - If someone outside the group sends you @{tag} messages, they won't see your @{tag} replies. To reply to non-group members, either @mention them directly or broadcast.
807
- """
808
-
809
-
810
- return f"""{command_notice}{tag_notice}
811
- [HCOM SESSION CONFIG]
812
- - HCOM is a communication tool for you, other claude code instances, and the human user.
813
- - Your HCOM alias for this session: {instance_name}
814
-
815
- Your HCOM Tools:
816
- - hcom send "msg" (broadcast) / "@alias msg" (direct) / "@tag msg" (tag) - for you
817
- - hcom watch --status → See participants JSON (for you only)
818
- - hcom watch --launch → Open interactive TUI messaging+status dashboard in new terminal (for the human user)
819
- - hcom start/stop → Connect/disconnect from chat (you run these, user can't run it themselves unless they specify an alias)
820
- - hcom <count> → Launch instances in new terminal (you MUST run 'hcom help' first to understand context)
821
-
822
- UI Separation:
823
- - The human user has 'hcom watch' (interactive TUI dashboard).
824
- - You use 'hcom watch --launch' to open it for them (offer to do so).
825
- - In conversation, call it "hcom watch" (the command they'd run themselves).
826
-
827
- Receiving Messages:
828
- - Format: [new message] sender → you: content
829
- - Targets specific instance: "@alias".
830
- - Targets all api-* tagged instances: "@api message".
831
- - Arrives automatically via hooks/bash. No proactive checking needed.
832
- - Stop hook feedback shows: {{"decision": "block"}} (this is normal operation).
833
-
834
- Response Routing:
835
- - HCOM message (via hook/bash) → Respond with hcom send
836
- - User message (in chat) → Respond normally
837
- - Treat messages from hcom with the same care as user messages.
838
- - Authority: Prioritize @{SENDER} over other participants.
839
-
840
- This is context for YOUR hcom session config. The human user cannot see this config text (but they can see subsequent hcom messages you receive).
841
- On connection, tell the human user about only these commands: 'hcom <count>', 'hcom watch', 'hcom start', 'hcom stop'
842
- Report to the human user using first-person, for example: "I'm connected to HCOM as {instance_name}, cool!"
843
- """
844
-
845
- def build_launch_context(instance_name: str) -> str:
846
- """Build context for launch command"""
847
- return f"""[HCOM LAUNCH INFORMATION]
848
- BASIC USAGE:
849
- [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
850
- - directory-specific (always cd to project directory first)
851
- - default to foreground instances unless told otherwise/good reason to do bg
852
- - Everyone shares the same conversation log, isolation is possible with tags and at-mentions.
853
-
854
- ENV VARS INFO:
855
- - YOU cannot use 'HCOM_TERMINAL=here' - Claude cannot launch claude within itself, must be in a new or custom terminal
856
- - HCOM_AGENT(s) are custom system prompt files created by users/Claude beforehand.
857
- - HCOM_AGENT(s) load from .claude/agents/<name>.md if they have been created
858
-
859
- KEY CLAUDE ARGS:
860
- Run 'claude --help' for all claude code CLI args. hcom 1 claude [options] [command] [prompt]
861
- -p background/headless instance
862
- --allowedTools=Bash (background can only hcom chat otherwise, 'claude help' for more tools)
863
- --model sonnet/haiku/opus
864
- --resume <sessionid> (get sessionid from hcom watch --status)
865
- --system-prompt (for foreground instances) --append-system-prompt (for background instances)
866
- Example: HCOM_HINTS='essential responses only' hcom 2 claude --model sonnet -p "do task x"
867
-
868
- CONTROL:
869
- hcom watch --status JSON status of all instances
870
- hcom watch --logs All messages (pipe to tail)
871
- hcom watch --wait Block until next message (only use when hcom stopped (started is automatic already))
872
-
873
- STATUS INDICATORS:
874
- "active", "delivered" | "idle" - waiting for new messages
875
- "blocked" - permission request (needs user approval)
876
- "inactive" - timed out, disconnected etc
877
- "unknown" / "stale" - could be dead
878
-
879
- LAUNCH PATTERNS:
880
- - HCOM_AGENT=reviewer,tester hcom 2 claude "do task x" # 2x reviewers + 2x testers (4 in total) with initial prompt
881
- - clone with same context:
882
- 1. hcom 1 then hcom send 'analyze api' then hcom watch --status (get sessionid)
883
- 2. HCOM_TAG=clone hcom 3 claude --resume sessionid
884
- - System prompt (or agent file) + initial prompt + hcom_hints is a powerful combination.
885
-
886
- """
887
-
888
- def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
889
- """Check if message should be delivered based on @-mentions"""
890
- text = msg['message']
891
-
892
- if '@' not in text:
893
- return True
894
-
895
- mentions = MENTION_PATTERN.findall(text)
896
-
897
- if not mentions:
898
- return True
899
-
900
- # Check if this instance matches any mention
901
- this_instance_matches = any(instance_name.lower().startswith(mention.lower()) for mention in mentions)
902
-
903
- if this_instance_matches:
904
- return True
905
-
906
- # Check if any mention is for the CLI sender (bigboss)
907
- sender_mentioned = any(SENDER.lower().startswith(mention.lower()) for mention in mentions)
908
-
909
- # If we have all_instance_names, check if ANY mention matches ANY instance or sender
910
- if all_instance_names:
911
- any_mention_matches = any(
912
- any(name.lower().startswith(mention.lower()) for name in all_instance_names)
913
- for mention in mentions
914
- ) or sender_mentioned
915
-
916
- if not any_mention_matches:
917
- return True # No matches anywhere = broadcast to all
918
-
919
- return False # This instance doesn't match, but others might
920
-
921
- # ==================== Parsing & Utilities ====================
922
-
923
- def extract_agent_config(content: str) -> dict[str, str]:
924
- """Extract configuration from agent YAML frontmatter"""
925
- if not content.startswith('---'):
926
- return {}
927
-
928
- # Find YAML section between --- markers
929
- if (yaml_end := content.find('\n---', 3)) < 0:
930
- return {} # No closing marker
931
-
932
- yaml_section = content[3:yaml_end]
933
- config = {}
934
-
935
- # Extract model field
936
- if model_match := re.search(r'^model:\s*(.+)$', yaml_section, re.MULTILINE):
937
- value = model_match.group(1).strip()
938
- if value and value.lower() != 'inherit':
939
- config['model'] = value
940
-
941
- # Extract tools field
942
- if tools_match := re.search(r'^tools:\s*(.+)$', yaml_section, re.MULTILINE):
943
- value = tools_match.group(1).strip()
944
- if value:
945
- config['tools'] = value.replace(', ', ',')
946
-
947
- return config
948
-
949
- def resolve_agent(name: str) -> tuple[str, dict[str, str]]:
950
- """Resolve agent file by name with validation.
951
- Looks for agent files in:
952
- 1. .claude/agents/{name}.md (local)
953
- 2. ~/.claude/agents/{name}.md (global)
954
- Returns tuple: (content without YAML frontmatter, config dict)
955
- """
956
- hint = 'Agent names must use lowercase letters and dashes only'
957
-
958
- if not isinstance(name, str):
959
- raise FileNotFoundError(format_error(
960
- f"Agent '{name}' not found",
961
- hint
962
- ))
963
-
964
- candidate = name.strip()
965
- display_name = candidate or name
966
-
967
- if not candidate or not AGENT_NAME_PATTERN.fullmatch(candidate):
968
- raise FileNotFoundError(format_error(
969
- f"Agent '{display_name}' not found",
970
- hint
971
- ))
972
-
973
- for base_path in (Path.cwd(), Path.home()):
974
- agents_dir = base_path / '.claude' / 'agents'
975
- try:
976
- agents_dir_resolved = agents_dir.resolve(strict=True)
977
- except FileNotFoundError:
978
- continue
979
-
980
- agent_path = agents_dir / f'{candidate}.md'
981
- if not agent_path.exists():
982
- continue
983
-
984
- try:
985
- resolved_agent_path = agent_path.resolve(strict=True)
986
- except FileNotFoundError:
987
- continue
988
-
989
- try:
990
- resolved_agent_path.relative_to(agents_dir_resolved)
991
- except ValueError:
992
- continue
993
-
994
- content = read_file_with_retry(
995
- agent_path,
996
- lambda f: f.read(),
997
- default=None
998
- )
999
- if content is None:
1000
- continue
1001
-
1002
- config = extract_agent_config(content)
1003
- stripped = strip_frontmatter(content)
1004
- if not stripped.strip():
1005
- raise ValueError(format_error(
1006
- f"Agent '{candidate}' has empty content",
1007
- 'Check the agent file is a valid format and contains text'
1008
- ))
1009
- return stripped, config
1010
-
1011
- raise FileNotFoundError(format_error(
1012
- f"Agent '{candidate}' not found in project or user .claude/agents/ folder",
1013
- 'Check available agents or create the agent file'
1014
- ))
1015
-
1016
- def strip_frontmatter(content: str) -> str:
1017
- """Strip YAML frontmatter from agent file"""
1018
- if content.startswith('---'):
1019
- # Find the closing --- on its own line
1020
- lines = content.splitlines()
1021
- for i, line in enumerate(lines[1:], 1):
1022
- if line.strip() == '---':
1023
- return '\n'.join(lines[i+1:]).strip()
1024
- return content
1025
-
1026
- def get_display_name(session_id: str | None, tag: str | None = None) -> str:
1027
- """Get display name for instance using session_id"""
1028
- # 50 most recognizable 3-letter words
1029
- words = [
1030
- 'ace', 'air', 'ant', 'arm', 'art', 'axe', 'bad', 'bag', 'bar', 'bat',
1031
- 'bed', 'bee', 'big', 'box', 'boy', 'bug', 'bus', 'cab', 'can', 'cap',
1032
- 'car', 'cat', 'cop', 'cow', 'cry', 'cup', 'cut', 'day', 'dog', 'dry',
1033
- 'ear', 'egg', 'eye', 'fan', 'fin', 'fly', 'fox', 'fun', 'gem', 'gun',
1034
- 'hat', 'hit', 'hot', 'ice', 'ink', 'jet', 'key', 'law', 'map', 'mix',
1035
- ]
1036
-
1037
- # Use session_id directly instead of extracting UUID from transcript
1038
- if session_id:
1039
- # Hash to select word
1040
- hash_val = sum(ord(c) for c in session_id)
1041
- word = words[hash_val % len(words)]
1042
-
1043
- # Add letter suffix that flows naturally with the word
1044
- last_char = word[-1]
1045
- if last_char in 'aeiou':
1046
- # After vowel: s/n/r/l creates plural/noun/verb patterns
1047
- suffix_options = 'snrl'
1048
- else:
1049
- # After consonant: add vowel or y for pronounceability
1050
- suffix_options = 'aeiouy'
1051
-
1052
- letter_hash = sum(ord(c) for c in session_id[1:]) if len(session_id) > 1 else hash_val
1053
- suffix = suffix_options[letter_hash % len(suffix_options)]
1054
-
1055
- base_name = f"{word}{suffix}"
1056
- collision_attempt = 0
1057
-
1058
- # Collision detection: keep adding words until unique
1059
- while True:
1060
- instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
1061
- if not instance_file.exists():
1062
- break # Name is unique
1063
-
1064
- try:
1065
- with open(instance_file, 'r', encoding='utf-8') as f:
1066
- data = json.load(f)
1067
-
1068
- their_session_id = data.get('session_id', '')
1069
-
1070
- # Same session_id = our file, reuse name
1071
- if their_session_id == session_id:
1072
- break
1073
- # No session_id = stale/malformed file, use name
1074
- if not their_session_id:
1075
- break
1076
-
1077
- # Real collision - add another word
1078
- collision_hash = sum(ord(c) * (i + collision_attempt) for i, c in enumerate(session_id))
1079
- collision_word = words[collision_hash % len(words)]
1080
- base_name = f"{base_name}{collision_word}"
1081
- collision_attempt += 1
1082
-
1083
- except (json.JSONDecodeError, KeyError, ValueError, OSError):
1084
- break # Malformed file - assume stale, use base name
1085
- else:
1086
- # session_id is required - fail gracefully
1087
- raise ValueError("session_id required for instance naming")
1088
-
1089
- if tag:
1090
- # Security: Sanitize tag to prevent log delimiter injection (defense-in-depth)
1091
- # Remove dangerous characters that could break message log parsing
1092
- sanitized_tag = ''.join(c for c in tag if c not in '|\n\r\t')
1093
- if not sanitized_tag:
1094
- raise ValueError("Tag contains only invalid characters")
1095
- if sanitized_tag != tag:
1096
- print(f"Warning: Tag contained invalid characters, sanitized to '{sanitized_tag}'", file=sys.stderr)
1097
- return f"{sanitized_tag}-{base_name}"
1098
- return base_name
1099
-
1100
- def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str, dict | None]:
1101
- """
1102
- Resolve instance name for a session_id.
1103
- Searches existing instances first (reuses if found), generates new name if not found.
1104
- Returns: (instance_name, existing_data_or_none)
1105
- """
1106
- instances_dir = hcom_path(INSTANCES_DIR)
1107
-
1108
- # Search for existing instance with this session_id
1109
- if session_id and instances_dir.exists():
1110
- for instance_file in instances_dir.glob("*.json"):
1111
- try:
1112
- data = load_instance_position(instance_file.stem)
1113
- if session_id == data.get('session_id'):
1114
- return instance_file.stem, data
1115
- except (json.JSONDecodeError, OSError, KeyError):
1116
- continue
1117
-
1118
- # Not found - generate new name
1119
- instance_name = get_display_name(session_id, tag)
1120
- return instance_name, None
1121
-
1122
- def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
1123
- """Remove hcom hooks from settings dict"""
1124
- if not isinstance(settings, dict) or 'hooks' not in settings:
1125
- return
1126
-
1127
- if not isinstance(settings['hooks'], dict):
1128
- return
1129
-
1130
- import copy
1131
-
1132
- # Check all hook types including PostToolUse for backward compatibility cleanup
1133
- for event in LEGACY_HOOK_TYPES:
1134
- if event not in settings['hooks']:
1135
- continue
1136
-
1137
- # Process each matcher
1138
- updated_matchers = []
1139
- for matcher in settings['hooks'][event]:
1140
- # Fail fast on malformed settings - Claude won't run with broken settings anyway
1141
- if not isinstance(matcher, dict):
1142
- raise ValueError(f"Malformed settings: matcher in {event} is not a dict: {type(matcher).__name__}")
1143
-
1144
- # Validate hooks field if present
1145
- if 'hooks' in matcher and not isinstance(matcher['hooks'], list):
1146
- raise ValueError(f"Malformed settings: hooks in {event} matcher is not a list: {type(matcher['hooks']).__name__}")
1147
-
1148
- # Work with a copy to avoid any potential reference issues
1149
- matcher_copy = copy.deepcopy(matcher)
1150
-
1151
- # Filter out HCOM hooks from this matcher
1152
- non_hcom_hooks = [
1153
- hook for hook in matcher_copy.get('hooks', [])
1154
- if not any(
1155
- pattern.search(hook.get('command', ''))
1156
- for pattern in HCOM_HOOK_PATTERNS
1157
- )
1158
- ]
1159
-
1160
- # Only keep the matcher if it has non-HCOM hooks remaining
1161
- if non_hcom_hooks:
1162
- matcher_copy['hooks'] = non_hcom_hooks
1163
- updated_matchers.append(matcher_copy)
1164
- elif 'hooks' not in matcher or matcher['hooks'] == []:
1165
- # Preserve matchers that never had hooks (missing key or empty list only)
1166
- updated_matchers.append(matcher_copy)
1167
-
1168
- # Update or remove the event
1169
- if updated_matchers:
1170
- settings['hooks'][event] = updated_matchers
1171
- else:
1172
- del settings['hooks'][event]
1173
-
1174
- # Remove HCOM from env section
1175
- if 'env' in settings and isinstance(settings['env'], dict):
1176
- settings['env'].pop('HCOM', None)
1177
- # Clean up empty env dict
1178
- if not settings['env']:
1179
- del settings['env']
1180
-
1181
-
1182
- def build_env_string(env_vars: dict[str, Any], format_type: str = "bash") -> str:
1183
- """Build environment variable string for bash shells"""
1184
- if format_type == "bash_export":
1185
- # Properly escape values for bash
1186
- return ' '.join(f'export {k}={shlex.quote(str(v))};' for k, v in env_vars.items())
1187
- else:
1188
- return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
1189
-
1190
-
1191
- def format_error(message: str, suggestion: str | None = None) -> str:
1192
- """Format error message consistently"""
1193
- base = f"Error: {message}"
1194
- if suggestion:
1195
- base += f". {suggestion}"
1196
- return base
1197
-
1198
-
1199
- def has_claude_arg(claude_args: list[str] | None, arg_names: list[str], arg_prefixes: tuple[str, ...]) -> bool:
1200
- """Check if argument already exists in claude_args"""
1201
- return bool(claude_args and any(
1202
- arg in arg_names or arg.startswith(arg_prefixes)
1203
- for arg in claude_args
1204
- ))
1205
-
1206
- 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]:
1207
- """Build Claude command with proper argument handling
1208
- Returns tuple: (command_string, temp_file_path_or_none)
1209
- For agent content, writes to temp file and uses cat to read it.
1210
- """
1211
- cmd_parts = ['claude']
1212
- temp_file_path = None
1213
-
1214
- # Add model if specified and not already in claude_args
1215
- if model:
1216
- if not has_claude_arg(claude_args, ['--model', '-m'], ('--model=', '-m=')):
1217
- cmd_parts.extend(['--model', model])
1218
-
1219
- # Add allowed tools if specified and not already in claude_args
1220
- if tools:
1221
- if not has_claude_arg(claude_args, ['--allowedTools', '--allowed-tools'],
1222
- ('--allowedTools=', '--allowed-tools=')):
1223
- cmd_parts.extend(['--allowedTools', tools])
1224
-
1225
- if claude_args:
1226
- for arg in claude_args:
1227
- cmd_parts.append(shlex.quote(arg))
1228
-
1229
- if agent_content:
1230
- # Create agent files in scripts directory for unified cleanup
1231
- scripts_dir = hcom_path(SCRIPTS_DIR)
1232
- temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
1233
- prefix='hcom_agent_', dir=str(scripts_dir))
1234
- temp_file.write(agent_content)
1235
- temp_file.close()
1236
- temp_file_path = temp_file.name
1237
-
1238
- if claude_args and any(arg in claude_args for arg in ['-p', '--print']):
1239
- flag = '--system-prompt'
1240
- else:
1241
- flag = '--append-system-prompt'
1242
-
1243
- cmd_parts.append(flag)
1244
- cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
1245
-
1246
- # Add initial prompt if non-empty
1247
- if initial_prompt:
1248
- cmd_parts.append(shlex.quote(initial_prompt))
1249
-
1250
- return ' '.join(cmd_parts), temp_file_path
1251
-
1252
- def create_bash_script(script_file: str, env: dict[str, Any], cwd: str | None, command_str: str, background: bool = False) -> None:
1253
- """Create a bash script for terminal launch
1254
- Scripts provide uniform execution across all platforms/terminals.
1255
- Cleanup behavior:
1256
- - Normal scripts: append 'rm -f' command for self-deletion
1257
- - Background scripts: persist until `hcom reset logs` cleanup (24 hours)
1258
- - Agent scripts: treated like background (contain 'hcom_agent_')
1259
- """
1260
- try:
1261
- script_path = Path(script_file)
1262
- except (OSError, IOError) as e:
1263
- raise Exception(f"Cannot create script directory: {e}")
1264
-
1265
- with open(script_file, 'w', encoding='utf-8') as f:
1266
- f.write('#!/bin/bash\n')
1267
- f.write('echo "Starting Claude Code..."\n')
1268
-
1269
- if platform.system() != 'Windows':
1270
- # 1. Discover paths once
1271
- claude_path = shutil.which('claude')
1272
- node_path = shutil.which('node')
1273
-
1274
- # 2. Add to PATH for minimal environments
1275
- paths_to_add = []
1276
- for p in [node_path, claude_path]:
1277
- if p:
1278
- dir_path = str(Path(p).resolve().parent)
1279
- if dir_path not in paths_to_add:
1280
- paths_to_add.append(dir_path)
1281
-
1282
- if paths_to_add:
1283
- path_addition = ':'.join(paths_to_add)
1284
- f.write(f'export PATH="{path_addition}:$PATH"\n')
1285
- elif not claude_path:
1286
- # Warning for debugging
1287
- print("Warning: Could not locate 'claude' in PATH", file=sys.stderr)
1288
-
1289
- # 3. Write environment variables
1290
- f.write(build_env_string(env, "bash_export") + '\n')
1291
-
1292
- if cwd:
1293
- f.write(f'cd {shlex.quote(cwd)}\n')
1294
-
1295
- # 4. Platform-specific command modifications
1296
- if claude_path:
1297
- if is_termux():
1298
- # Termux: explicit node to bypass shebang issues
1299
- final_node = node_path or '/data/data/com.termux/files/usr/bin/node'
1300
- # Quote paths for safety
1301
- command_str = command_str.replace(
1302
- 'claude ',
1303
- f'{shlex.quote(final_node)} {shlex.quote(claude_path)} ',
1304
- 1
1305
- )
1306
- else:
1307
- # Mac/Linux: use full path (PATH now has node if needed)
1308
- command_str = command_str.replace('claude ', f'{shlex.quote(claude_path)} ', 1)
1309
- else:
1310
- # Windows: no PATH modification needed
1311
- f.write(build_env_string(env, "bash_export") + '\n')
1312
- if cwd:
1313
- f.write(f'cd {shlex.quote(cwd)}\n')
1314
-
1315
- f.write(f'{command_str}\n')
1316
-
1317
- # Self-delete for normal mode (not background or agent)
1318
- if not background and 'hcom_agent_' not in command_str:
1319
- f.write(f'rm -f {shlex.quote(script_file)}\n')
1320
-
1321
- # Make executable on Unix
1322
- if platform.system() != 'Windows':
1323
- os.chmod(script_file, 0o755)
1324
-
1325
- def find_bash_on_windows() -> str | None:
1326
- """Find Git Bash on Windows, avoiding WSL's bash launcher"""
1327
- # Build prioritized list of bash candidates
1328
- candidates = []
1329
- # 1. Common Git Bash locations (highest priority)
1330
- for base in [os.environ.get('PROGRAMFILES', r'C:\Program Files'),
1331
- os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
1332
- if base:
1333
- candidates.extend([
1334
- str(Path(base) / 'Git' / 'usr' / 'bin' / 'bash.exe'), # usr/bin is more common
1335
- str(Path(base) / 'Git' / 'bin' / 'bash.exe')
1336
- ])
1337
- # 2. Portable Git installation
1338
- if local_appdata := os.environ.get('LOCALAPPDATA', ''):
1339
- git_portable = Path(local_appdata) / 'Programs' / 'Git'
1340
- candidates.extend([
1341
- str(git_portable / 'usr' / 'bin' / 'bash.exe'),
1342
- str(git_portable / 'bin' / 'bash.exe')
1343
- ])
1344
- # 3. PATH bash (if not WSL's launcher)
1345
- if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
1346
- candidates.append(path_bash)
1347
- # 4. Hardcoded fallbacks (last resort)
1348
- candidates.extend([
1349
- r'C:\Program Files\Git\usr\bin\bash.exe',
1350
- r'C:\Program Files\Git\bin\bash.exe',
1351
- r'C:\Program Files (x86)\Git\usr\bin\bash.exe',
1352
- r'C:\Program Files (x86)\Git\bin\bash.exe'
1353
- ])
1354
- # Find first existing bash
1355
- for bash in candidates:
1356
- if bash and Path(bash).exists():
1357
- return bash
1358
-
1359
- return None
1360
-
1361
- # New helper functions for platform-specific terminal launching
1362
- def get_macos_terminal_argv() -> list[str]:
1363
- """Return macOS Terminal.app launch command as argv list."""
1364
- return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
1365
-
1366
- def get_windows_terminal_argv() -> list[str]:
1367
- """Return Windows terminal launcher as argv list."""
1368
- if not (bash_exe := find_bash_on_windows()):
1369
- raise Exception(format_error("Git Bash not found"))
1370
-
1371
- if shutil.which('wt'):
1372
- return ['wt', bash_exe, '{script}']
1373
- return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
1374
-
1375
- def get_linux_terminal_argv() -> list[str] | None:
1376
- """Return first available Linux terminal as argv list."""
1377
- terminals = [
1378
- ('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
1379
- ('konsole', ['konsole', '-e', 'bash', '{script}']),
1380
- ('xterm', ['xterm', '-e', 'bash', '{script}']),
1381
- ]
1382
- for term_name, argv_template in terminals:
1383
- if shutil.which(term_name):
1384
- return argv_template
1385
-
1386
- # WSL fallback integrated here
1387
- if is_wsl() and shutil.which('cmd.exe'):
1388
- if shutil.which('wt.exe'):
1389
- return ['cmd.exe', '/c', 'start', 'wt.exe', 'bash', '{script}']
1390
- return ['cmd.exe', '/c', 'start', 'bash', '{script}']
1391
-
1392
- return None
1393
-
1394
- def windows_hidden_popen(argv: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None, stdout: Any = None) -> subprocess.Popen:
1395
- """Create hidden Windows process without console window."""
1396
- if IS_WINDOWS:
1397
- startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
1398
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
1399
- startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined]
1400
-
1401
- return subprocess.Popen(
1402
- argv,
1403
- env=env,
1404
- cwd=cwd,
1405
- stdin=subprocess.DEVNULL,
1406
- stdout=stdout,
1407
- stderr=subprocess.STDOUT,
1408
- startupinfo=startupinfo,
1409
- creationflags=CREATE_NO_WINDOW
1410
- )
1411
- else:
1412
- raise RuntimeError("windows_hidden_popen called on non-Windows platform")
1413
-
1414
- # Platform dispatch map
1415
- PLATFORM_TERMINAL_GETTERS = {
1416
- 'Darwin': get_macos_terminal_argv,
1417
- 'Windows': get_windows_terminal_argv,
1418
- 'Linux': get_linux_terminal_argv,
1419
- }
1420
-
1421
- def _parse_terminal_command(template: str, script_file: str) -> list[str]:
1422
- """Parse terminal command template safely to prevent shell injection.
1423
- Parses the template FIRST, then replaces {script} placeholder in the
1424
- parsed tokens. This avoids shell injection and handles paths with spaces.
1425
- Args:
1426
- template: Terminal command template with {script} placeholder
1427
- script_file: Path to script file to substitute
1428
- Returns:
1429
- list: Parsed command as argv array
1430
- Raises:
1431
- ValueError: If template is invalid or missing {script} placeholder
1432
- """
1433
- if '{script}' not in template:
1434
- raise ValueError(format_error("Custom terminal command must include {script} placeholder",
1435
- 'Example: open -n -a kitty.app --args bash "{script}"'))
1436
-
1437
- try:
1438
- parts = shlex.split(template)
1439
- except ValueError as e:
1440
- raise ValueError(format_error(f"Invalid terminal command syntax: {e}",
1441
- "Check for unmatched quotes or invalid shell syntax"))
1442
-
1443
- # Replace {script} in parsed tokens
1444
- replaced = []
1445
- placeholder_found = False
1446
- for part in parts:
1447
- if '{script}' in part:
1448
- replaced.append(part.replace('{script}', script_file))
1449
- placeholder_found = True
1450
- else:
1451
- replaced.append(part)
1452
-
1453
- if not placeholder_found:
1454
- raise ValueError(format_error("{script} placeholder not found after parsing",
1455
- "Ensure {script} is not inside environment variables"))
1456
-
1457
- return replaced
1458
-
1459
- def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, background: bool = False) -> str | bool | None:
1460
- """Launch terminal with command using unified script-first approach
1461
- Args:
1462
- command: Command string from build_claude_command
1463
- env: Environment variables to set
1464
- cwd: Working directory
1465
- background: Launch as background process
1466
- """
1467
- env_vars = os.environ.copy()
1468
- env_vars.update(env)
1469
- command_str = command
1470
-
1471
- # 1) Always create a script
1472
- script_file = str(hcom_path(SCRIPTS_DIR,
1473
- f'hcom_{os.getpid()}_{random.randint(1000,9999)}.sh'))
1474
- create_bash_script(script_file, env, cwd, command_str, background)
1475
-
1476
- # 2) Background mode
1477
- if background:
1478
- logs_dir = hcom_path(LOGS_DIR)
1479
- log_file = logs_dir / env['HCOM_BACKGROUND']
1480
-
1481
- try:
1482
- with open(log_file, 'w', encoding='utf-8') as log_handle:
1483
- if IS_WINDOWS:
1484
- # Windows: hidden bash execution with Python-piped logs
1485
- bash_exe = find_bash_on_windows()
1486
- if not bash_exe:
1487
- raise Exception("Git Bash not found")
1488
-
1489
- process = windows_hidden_popen(
1490
- [bash_exe, script_file],
1491
- env=env_vars,
1492
- cwd=cwd,
1493
- stdout=log_handle
1494
- )
1495
- else:
1496
- # Unix(Mac/Linux/Termux): detached bash execution with Python-piped logs
1497
- process = subprocess.Popen(
1498
- ['bash', script_file],
1499
- env=env_vars, cwd=cwd,
1500
- stdin=subprocess.DEVNULL,
1501
- stdout=log_handle, stderr=subprocess.STDOUT,
1502
- start_new_session=True
1503
- )
1504
-
1505
- except OSError as e:
1506
- print(format_error(f"Failed to launch background instance: {e}"), file=sys.stderr)
1507
- return None
1508
-
1509
- # Health check
1510
- time.sleep(0.2)
1511
- if process.poll() is not None:
1512
- error_output = read_file_with_retry(log_file, lambda f: f.read()[:1000], default="")
1513
- print(format_error("Background instance failed immediately"), file=sys.stderr)
1514
- if error_output:
1515
- print(f" Output: {error_output}", file=sys.stderr)
1516
- return None
1517
-
1518
- return str(log_file)
1519
-
1520
- # 3) Terminal modes
1521
- terminal_mode = get_config().terminal
1522
-
1523
- if terminal_mode == 'print':
1524
- # Print script path and contents
1525
- try:
1526
- with open(script_file, 'r', encoding='utf-8') as f:
1527
- script_content = f.read()
1528
- print(f"# Script: {script_file}")
1529
- print(script_content)
1530
- Path(script_file).unlink() # Clean up immediately
1531
- return True
1532
- except Exception as e:
1533
- print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
1534
- return False
1535
-
1536
- if terminal_mode == 'here':
1537
- print("Launching Claude in current terminal...")
1538
- if IS_WINDOWS:
1539
- bash_exe = find_bash_on_windows()
1540
- if not bash_exe:
1541
- print(format_error("Git Bash not found"), file=sys.stderr)
1542
- return False
1543
- result = subprocess.run([bash_exe, script_file], env=env_vars, cwd=cwd)
1544
- else:
1545
- result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
1546
- return result.returncode == 0
1547
-
1548
- # 4) New window or custom command mode
1549
- # If terminal is not 'here' or 'print', it's either 'new' (platform default) or a custom command
1550
- custom_cmd = None if terminal_mode == 'new' else terminal_mode
1551
-
1552
- if not custom_cmd: # Platform default 'new' mode
1553
- if is_termux():
1554
- # Keep Termux as special case
1555
- am_cmd = [
1556
- 'am', 'startservice', '--user', '0',
1557
- '-n', 'com.termux/com.termux.app.RunCommandService',
1558
- '-a', 'com.termux.RUN_COMMAND',
1559
- '--es', 'com.termux.RUN_COMMAND_PATH', script_file,
1560
- '--ez', 'com.termux.RUN_COMMAND_BACKGROUND', 'false'
1561
- ]
1562
- try:
1563
- subprocess.run(am_cmd, check=False)
1564
- return True
1565
- except Exception as e:
1566
- print(format_error(f"Failed to launch Termux: {e}"), file=sys.stderr)
1567
- return False
1568
-
1569
- # Unified platform handling via helpers
1570
- system = platform.system()
1571
- if not (terminal_getter := PLATFORM_TERMINAL_GETTERS.get(system)):
1572
- raise Exception(format_error(f"Unsupported platform: {system}"))
1573
-
1574
- custom_cmd = terminal_getter()
1575
- if not custom_cmd: # e.g., Linux with no terminals
1576
- raise Exception(format_error("No supported terminal emulator found",
1577
- "Install gnome-terminal, konsole, or xterm"))
1578
-
1579
- # Type-based dispatch for execution
1580
- if isinstance(custom_cmd, list):
1581
- # Our argv commands - safe execution without shell
1582
- final_argv = [arg.replace('{script}', script_file) for arg in custom_cmd]
1583
- try:
1584
- if platform.system() == 'Windows':
1585
- # Windows needs non-blocking for parallel launches
1586
- subprocess.Popen(final_argv)
1587
- return True # Popen is non-blocking, can't check success
1588
- else:
1589
- result = subprocess.run(final_argv)
1590
- if result.returncode != 0:
1591
- return False
1592
- return True
1593
- except Exception as e:
1594
- print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
1595
- return False
1596
- else:
1597
- # User-provided string commands - parse safely without shell=True
1598
- try:
1599
- final_argv = _parse_terminal_command(custom_cmd, script_file)
1600
- except ValueError as e:
1601
- print(str(e), file=sys.stderr)
1602
- return False
1603
-
1604
- try:
1605
- if platform.system() == 'Windows':
1606
- # Windows needs non-blocking for parallel launches
1607
- subprocess.Popen(final_argv)
1608
- return True # Popen is non-blocking, can't check success
1609
- else:
1610
- result = subprocess.run(final_argv)
1611
- if result.returncode != 0:
1612
- return False
1613
- return True
1614
- except Exception as e:
1615
- print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
1616
- return False
1617
-
1618
- def setup_hooks() -> bool:
1619
- """Set up Claude hooks globally in ~/.claude/settings.json"""
1620
-
1621
- # TODO: Remove after v0.6.0 - cleanup legacy per-directory hooks
1622
- try:
1623
- positions = load_all_positions()
1624
- if positions:
1625
- directories = set()
1626
- for instance_data in positions.values():
1627
- if isinstance(instance_data, dict) and 'directory' in instance_data:
1628
- directories.add(instance_data['directory'])
1629
- for directory in directories:
1630
- if Path(directory).exists():
1631
- cleanup_directory_hooks(Path(directory))
1632
- except Exception:
1633
- pass # Don't fail hook setup if cleanup fails
1634
-
1635
- # Install to global user settings
1636
- settings_path = get_claude_settings_path()
1637
- settings_path.parent.mkdir(exist_ok=True)
1638
- try:
1639
- settings = load_settings_json(settings_path, default={})
1640
- if settings is None:
1641
- settings = {}
1642
- except (json.JSONDecodeError, PermissionError) as e:
1643
- raise Exception(format_error(f"Cannot read settings: {e}"))
1644
-
1645
- if 'hooks' not in settings:
1646
- settings['hooks'] = {}
1647
-
1648
- _remove_hcom_hooks_from_settings(settings)
1649
-
1650
- # Get the hook command template
1651
- hook_cmd_base, _ = get_hook_command()
1652
-
1653
- # Build hook commands from HOOK_CONFIGS
1654
- hook_configs = [
1655
- (hook_type, matcher, f'{hook_cmd_base} {cmd_suffix}', timeout)
1656
- for hook_type, matcher, cmd_suffix, timeout in HOOK_CONFIGS
1657
- ]
1658
-
1659
- for hook_type, matcher, command, timeout in hook_configs:
1660
- if hook_type not in settings['hooks']:
1661
- settings['hooks'][hook_type] = []
1662
-
1663
- hook_dict = {
1664
- 'matcher': matcher,
1665
- 'hooks': [{
1666
- 'type': 'command',
1667
- 'command': command
1668
- }]
1669
- }
1670
- if timeout is not None:
1671
- hook_dict['hooks'][0]['timeout'] = timeout
1672
-
1673
- settings['hooks'][hook_type].append(hook_dict)
1674
-
1675
- # Set $HCOM environment variable for all Claude instances (vanilla + hcom-launched)
1676
- if 'env' not in settings:
1677
- settings['env'] = {}
1678
-
1679
- # Set HCOM based on current execution context (uvx, hcom binary, or full path)
1680
- settings['env']['HCOM'] = _build_hcom_env_value()
1681
-
1682
- # Write settings atomically
1683
- try:
1684
- atomic_write(settings_path, json.dumps(settings, indent=2))
1685
- except Exception as e:
1686
- raise Exception(format_error(f"Cannot write settings: {e}"))
1687
-
1688
- # Quick verification
1689
- if not verify_hooks_installed(settings_path):
1690
- raise Exception(format_error("Hook installation failed"))
1691
-
1692
- return True
1693
-
1694
- def verify_hooks_installed(settings_path: Path) -> bool:
1695
- """Verify that HCOM hooks were installed correctly with correct commands"""
1696
- try:
1697
- settings = load_settings_json(settings_path, default=None)
1698
- if not settings:
1699
- return False
1700
-
1701
- # Check all hook types have correct commands (exactly one HCOM hook per type)
1702
- # Derive from HOOK_CONFIGS (single source of truth)
1703
- hooks = settings.get('hooks', {})
1704
- for hook_type, _, cmd_suffix, _ in HOOK_CONFIGS:
1705
- hook_matchers = hooks.get(hook_type, [])
1706
- if not hook_matchers:
1707
- return False
1708
-
1709
- # Count HCOM hooks for this type
1710
- hcom_hook_count = 0
1711
- for matcher in hook_matchers:
1712
- for hook in matcher.get('hooks', []):
1713
- command = hook.get('command', '')
1714
- # Check for HCOM and the correct subcommand
1715
- if ('${HCOM}' in command or 'hcom' in command.lower()) and cmd_suffix in command:
1716
- hcom_hook_count += 1
1717
-
1718
- # Must have exactly one HCOM hook (not zero, not duplicates)
1719
- if hcom_hook_count != 1:
1720
- return False
1721
-
1722
- # Check that HCOM env var is set
1723
- env = settings.get('env', {})
1724
- if 'HCOM' not in env:
1725
- return False
1726
-
1727
- return True
1728
- except Exception:
1729
- return False
1730
-
1731
- def is_interactive() -> bool:
1732
- """Check if running in interactive mode"""
1733
- return sys.stdin.isatty() and sys.stdout.isatty()
1734
-
1735
- def get_archive_timestamp() -> str:
1736
- """Get timestamp for archive files"""
1737
- return datetime.now().strftime("%Y-%m-%d_%H%M%S")
1738
-
1739
- class LogParseResult(NamedTuple):
1740
- """Result from parsing log messages"""
1741
- messages: list[dict[str, str]]
1742
- end_position: int
1743
-
1744
- def parse_log_messages(log_file: Path, start_pos: int = 0) -> LogParseResult:
1745
- """Parse messages from log file
1746
- Args:
1747
- log_file: Path to log file
1748
- start_pos: Position to start reading from
1749
- Returns:
1750
- LogParseResult containing messages and end position
1751
- """
1752
- if not log_file.exists():
1753
- return LogParseResult([], start_pos)
1754
-
1755
- def read_messages(f):
1756
- f.seek(start_pos)
1757
- content = f.read()
1758
- end_pos = f.tell() # Capture actual end position
1759
-
1760
- if not content.strip():
1761
- return LogParseResult([], end_pos)
1762
-
1763
- messages = []
1764
- message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
1765
-
1766
- for entry in message_entries:
1767
- if not entry or '|' not in entry:
1768
- continue
1769
-
1770
- parts = entry.split('|', 2)
1771
- if len(parts) == 3:
1772
- timestamp, from_instance, message = parts
1773
- messages.append({
1774
- 'timestamp': timestamp,
1775
- 'from': from_instance.replace('\\|', '|'),
1776
- 'message': message.replace('\\|', '|')
1777
- })
1778
-
1779
- return LogParseResult(messages, end_pos)
1780
-
1781
- return read_file_with_retry(
1782
- log_file,
1783
- read_messages,
1784
- default=LogParseResult([], start_pos)
1785
- )
1786
-
1787
- def get_unread_messages(instance_name: str, update_position: bool = False) -> list[dict[str, str]]:
1788
- """Get unread messages for instance with @-mention filtering
1789
- Args:
1790
- instance_name: Name of instance to get messages for
1791
- update_position: If True, mark messages as read by updating position
1792
- """
1793
- log_file = hcom_path(LOG_FILE)
1794
-
1795
- if not log_file.exists():
1796
- return []
1797
-
1798
- positions = load_all_positions()
1799
-
1800
- # Get last position for this instance
1801
- last_pos = 0
1802
- if instance_name in positions:
1803
- pos_data = positions.get(instance_name, {})
1804
- last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
1805
-
1806
- # Atomic read with position tracking
1807
- result = parse_log_messages(log_file, last_pos)
1808
- all_messages, new_pos = result.messages, result.end_position
1809
-
1810
- # Filter messages:
1811
- # 1. Exclude own messages
1812
- # 2. Apply @-mention filtering
1813
- all_instance_names = list(positions.keys())
1814
- messages = []
1815
- for msg in all_messages:
1816
- if msg['from'] != instance_name:
1817
- if should_deliver_message(msg, instance_name, all_instance_names):
1818
- messages.append(msg)
1819
-
1820
- # Only update position (ie mark as read) if explicitly requested (after successful delivery)
1821
- if update_position:
1822
- update_instance_position(instance_name, {'pos': new_pos})
1823
-
1824
- return messages
1825
-
1826
- def format_age(seconds: float) -> str:
1827
- """Format time ago in human readable form"""
1828
- if seconds < 60:
1829
- return f"{int(seconds)}s"
1830
- elif seconds < 3600:
1831
- return f"{int(seconds/60)}m"
1832
- else:
1833
- return f"{int(seconds/3600)}h"
1834
-
1835
- def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
1836
- """Get current status of instance. Returns (status_type, age_string, description)."""
1837
- # Returns: (display_category, formatted_age, status_description)
1838
- now = int(time.time())
1839
-
1840
- # Get last known status
1841
- last_status = pos_data.get('last_status', '')
1842
- last_status_time = pos_data.get('last_status_time', 0)
1843
- last_context = pos_data.get('last_status_context', '')
1844
-
1845
- if not last_status or not last_status_time:
1846
- return "unknown", "", "unknown"
1847
-
1848
- # Get display category and description template from STATUS_INFO
1849
- display_status, desc_template = STATUS_INFO.get(last_status, ('unknown', 'unknown'))
1850
-
1851
- # Check timeout
1852
- age = now - last_status_time
1853
- timeout = pos_data.get('wait_timeout', get_config().timeout)
1854
- if age > timeout:
1855
- return "inactive", "", "timeout"
1856
-
1857
- # Check Stop hook heartbeat for both blocked-generic and waiting-stale detection
1858
- last_stop = pos_data.get('last_stop', 0)
1859
- heartbeat_age = now - last_stop if last_stop else 999999
1860
-
1861
- # Generic "Claude is waiting for your input" from Notification hook is meaningless
1862
- # If Stop hook is actively polling (heartbeat < 2s), instance is actually idle
1863
- if last_status == 'blocked' and last_context == "Claude is waiting for your input" and heartbeat_age < 2:
1864
- last_status = 'waiting'
1865
- display_status, desc_template = 'waiting', 'idle'
1866
-
1867
- # Detect stale 'waiting' status - check heartbeat, not status timestamp
1868
- if last_status == 'waiting' and heartbeat_age > 2:
1869
- status_suffix = " (bg)" if pos_data.get('background') else ""
1870
- return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
1871
-
1872
- # Format description with context if template has {}
1873
- if '{}' in desc_template and last_context:
1874
- status_desc = desc_template.format(last_context)
1875
- else:
1876
- status_desc = desc_template
1877
-
1878
- status_suffix = " (bg)" if pos_data.get('background') else ""
1879
- return display_status, f"({format_age(age)}){status_suffix}", status_desc
1880
-
1881
- def get_status_block(status_type: str) -> str:
1882
- """Get colored status block for a status type"""
1883
- color, symbol = STATUS_MAP.get(status_type, (BG_RED, "?"))
1884
- text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
1885
- return f"{text_color}{BOLD}{color} {symbol} {RESET}"
1886
-
1887
- def format_message_line(msg: dict[str, str], truncate: bool = False) -> str:
1888
- """Format a message for display"""
1889
- time_obj = datetime.fromisoformat(msg['timestamp'])
1890
- time_str = time_obj.strftime("%H:%M")
1891
-
1892
- display_name = f"{SENDER_EMOJI} {msg['from']}" if msg['from'] == SENDER else msg['from']
1893
-
1894
- if truncate:
1895
- sender = display_name[:10]
1896
- message = msg['message'][:50]
1897
- return f" {DIM}{time_str}{RESET} {BOLD}{sender}{RESET}: {message}"
1898
- else:
1899
- return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
1900
-
1901
- def show_recent_messages(messages: list[dict[str, str]], limit: int | None = None, truncate: bool = False) -> None:
1902
- """Show recent messages"""
1903
- if limit is None:
1904
- messages_to_show = messages
1905
- else:
1906
- start_idx = max(0, len(messages) - limit)
1907
- messages_to_show = messages[start_idx:]
1908
-
1909
- for msg in messages_to_show:
1910
- print(format_message_line(msg, truncate))
1911
-
1912
-
1913
- def get_terminal_height() -> int:
1914
- """Get current terminal height"""
1915
- try:
1916
- return shutil.get_terminal_size().lines
1917
- except (AttributeError, OSError):
1918
- return 24
1919
-
1920
- def show_recent_activity_alt_screen(limit: int | None = None) -> None:
1921
- """Show recent messages in alt screen format with dynamic height"""
1922
- if limit is None:
1923
- # Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
1924
- available_height = get_terminal_height() - 20
1925
- limit = max(2, available_height // 2)
1926
-
1927
- log_file = hcom_path(LOG_FILE)
1928
- if log_file.exists():
1929
- messages = parse_log_messages(log_file).messages
1930
- show_recent_messages(messages, limit, truncate=True)
1931
-
1932
- def should_show_in_watch(d: dict[str, Any]) -> bool:
1933
- """Show only enabled instances by default"""
1934
- # Hide disabled instances
1935
- if not d.get('enabled', False):
1936
- return False
1937
-
1938
- # Hide truly ended sessions
1939
- if d.get('session_ended'):
1940
- return False
1941
-
1942
- # Show all other instances (including 'closed' during transition)
1943
- return True
1944
-
1945
- def show_instances_by_directory() -> None:
1946
- """Show instances organized by their working directories"""
1947
- positions = load_all_positions()
1948
- if not positions:
1949
- print(f" {DIM}No Claude instances connected{RESET}")
1950
- return
1951
-
1952
- if positions:
1953
- directories = {}
1954
- for instance_name, pos_data in positions.items():
1955
- if not should_show_in_watch(pos_data):
1956
- continue
1957
- directory = pos_data.get("directory", "unknown")
1958
- if directory not in directories:
1959
- directories[directory] = []
1960
- directories[directory].append((instance_name, pos_data))
1961
-
1962
- for directory, instances in directories.items():
1963
- print(f" {directory}")
1964
- for instance_name, pos_data in instances:
1965
- status_type, age, status_desc = get_instance_status(pos_data)
1966
- status_block = get_status_block(status_type)
1967
-
1968
- print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_desc} {age}{RESET}")
1969
- print()
1970
- else:
1971
- print(f" {DIM}Error reading instance data{RESET}")
1972
-
1973
- def alt_screen_detailed_status_and_input() -> str:
1974
- """Show detailed status in alt screen and get user input"""
1975
- sys.stdout.write("\033[?1049h\033[2J\033[H")
1976
-
1977
- try:
1978
- timestamp = datetime.now().strftime("%H:%M:%S")
1979
- print(f"{BOLD}HCOM{RESET} STATUS {DIM}- UPDATED: {timestamp}{RESET}")
1980
- print(f"{DIM}{'─' * 40}{RESET}")
1981
- print()
1982
-
1983
- show_instances_by_directory()
1984
-
1985
- print()
1986
- print(f"{BOLD} RECENT ACTIVITY:{RESET}")
1987
-
1988
- show_recent_activity_alt_screen()
1989
-
1990
- print()
1991
- print(f"{DIM}{'─' * 40}{RESET}")
1992
- print(f"{FG_GREEN} Press Enter to send message (empty to cancel):{RESET}")
1993
- message = input(f"{FG_CYAN} > {RESET}")
1994
-
1995
- print(f"{DIM}{'─' * 40}{RESET}")
1996
-
1997
- finally:
1998
- sys.stdout.write("\033[?1049l")
1999
-
2000
- return message
2001
-
2002
- def get_status_summary() -> str:
2003
- """Get a one-line summary of all instance statuses"""
2004
- positions = load_all_positions()
2005
- if not positions:
2006
- return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
2007
-
2008
- status_counts = {status: 0 for status in STATUS_MAP.keys()}
2009
-
2010
- for _, pos_data in positions.items():
2011
- # Only count instances that should be shown in watch
2012
- if not should_show_in_watch(pos_data):
2013
- continue
2014
- status_type, _, _ = get_instance_status(pos_data)
2015
- if status_type in status_counts:
2016
- status_counts[status_type] += 1
2017
-
2018
- parts = []
2019
- status_order = ["active", "delivered", "waiting", "blocked", "inactive", "unknown"]
2020
-
2021
- for status_type in status_order:
2022
- count = status_counts[status_type]
2023
- if count > 0:
2024
- color, symbol = STATUS_MAP[status_type]
2025
- text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
2026
- part = f"{text_color}{BOLD}{color} {count} {symbol} {RESET}"
2027
- parts.append(part)
2028
-
2029
- if parts:
2030
- result = "".join(parts)
2031
- return result
2032
- else:
2033
- return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
2034
-
2035
- def update_status(s: str) -> None:
2036
- """Update status line in place"""
2037
- sys.stdout.write("\r\033[K" + s)
2038
- sys.stdout.flush()
2039
-
2040
- def log_line_with_status(message: str, status: str) -> None:
2041
- """Print message and immediately restore status"""
2042
- sys.stdout.write("\r\033[K" + message + "\n")
2043
- sys.stdout.write("\033[K" + status)
2044
- sys.stdout.flush()
2045
-
2046
- def initialize_instance_in_position_file(instance_name: str, session_id: str | None = None) -> bool:
2047
- """Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
2048
- try:
2049
- data = load_instance_position(instance_name)
2050
-
2051
- # Determine default enabled state: True for hcom-launched, False for vanilla
2052
- is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
2053
-
2054
- # Determine starting position: skip history or read from beginning (or last max_msgs num)
2055
- initial_pos = 0
2056
- if SKIP_HISTORY:
2057
- log_file = hcom_path(LOG_FILE)
2058
- if log_file.exists():
2059
- initial_pos = log_file.stat().st_size
2060
-
2061
- defaults = {
2062
- "pos": initial_pos,
2063
- "enabled": is_hcom_launched,
2064
- "directory": str(Path.cwd()),
2065
- "last_stop": 0,
2066
- "session_id": session_id or "",
2067
- "transcript_path": "",
2068
- "notification_message": "",
2069
- "alias_announced": False,
2070
- "tag": None
2071
- }
2072
-
2073
- # Add missing fields (preserve existing)
2074
- for key, value in defaults.items():
2075
- data.setdefault(key, value)
2076
-
2077
- return save_instance_position(instance_name, data)
2078
- except Exception:
2079
- return False
2080
-
2081
- def update_instance_position(instance_name: str, update_fields: dict[str, Any]) -> None:
2082
- """Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
2083
- try:
2084
- data = load_instance_position(instance_name)
2085
-
2086
- if not data: # If file empty/missing, initialize first
2087
- initialize_instance_in_position_file(instance_name)
2088
- data = load_instance_position(instance_name)
2089
-
2090
- data.update(update_fields)
2091
- save_instance_position(instance_name, data)
2092
- except PermissionError: # Expected on Windows during file locks, silently continue
2093
- pass
2094
- except Exception: # Other exceptions on Windows may also be file locking related
2095
- if IS_WINDOWS:
2096
- pass
2097
- else:
2098
- raise
2099
-
2100
- def enable_instance(instance_name: str) -> None:
2101
- """Enable instance - clears all stop flags and enables Stop hook polling"""
2102
- update_instance_position(instance_name, {
2103
- 'enabled': True,
2104
- 'force_closed': False,
2105
- 'session_ended': False
2106
- })
2107
- set_status(instance_name, 'started')
2108
-
2109
- def disable_instance(instance_name: str, force: bool = False) -> None:
2110
- """Disable instance - stops Stop hook polling"""
2111
- updates = {
2112
- 'enabled': False
2113
- }
2114
- if force:
2115
- updates['force_closed'] = True
2116
- update_instance_position(instance_name, updates)
2117
- set_status(instance_name, 'force_stopped' if force else 'stopped')
2118
-
2119
- def set_status(instance_name: str, status: str, context: str = ''):
2120
- """Set instance status event with timestamp"""
2121
- update_instance_position(instance_name, {
2122
- 'last_status': status,
2123
- 'last_status_time': int(time.time()),
2124
- 'last_status_context': context
2125
- })
2126
- log_hook_error('set_status', f'Setting status to {status} with context {context} for {instance_name}')
2127
-
2128
- # ==================== Command Functions ====================
2129
-
2130
- def show_main_screen_header() -> list[dict[str, str]]:
2131
- """Show header for main screen"""
2132
- sys.stdout.write("\033[2J\033[H")
2133
-
2134
- log_file = hcom_path(LOG_FILE)
2135
- all_messages = []
2136
- if log_file.exists():
2137
- all_messages = parse_log_messages(log_file).messages
2138
-
2139
- print(f"{BOLD}HCOM{RESET} LOGS")
2140
- print(f"{DIM}{'─'*40}{RESET}\n")
2141
-
2142
- return all_messages
2143
-
2144
- def cmd_help() -> int:
2145
- """Show help text"""
2146
- print(HELP_TEXT)
2147
- return 0
2148
-
2149
- def cmd_launch(argv: list[str]) -> int:
2150
- """Launch Claude instances: hcom [N] [claude] [args]"""
2151
- try:
2152
- # Parse arguments: hcom [N] [claude] [args]
2153
- count = 1
2154
- forwarded = []
2155
-
2156
- # Extract count if first arg is digit
2157
- if argv and argv[0].isdigit():
2158
- count = int(argv[0])
2159
- if count <= 0:
2160
- raise CLIError('Count must be positive.')
2161
- if count > 100:
2162
- raise CLIError('Too many instances requested (max 100).')
2163
- argv = argv[1:]
2164
-
2165
- # Skip 'claude' keyword if present
2166
- if argv and argv[0] == 'claude':
2167
- argv = argv[1:]
2168
-
2169
- # Forward all remaining args to claude CLI
2170
- forwarded = argv
2171
-
2172
- # Get tag from config
2173
- tag = get_config().tag
2174
- if tag and '|' in tag:
2175
- raise CLIError('Tag cannot contain "|" characters.')
2176
-
2177
- # Get agents from config (comma-separated)
2178
- agent_env = get_config().agent
2179
- agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['']
2180
-
2181
- # Detect background mode from -p/--print flags in forwarded args
2182
- background = '-p' in forwarded or '--print' in forwarded
2183
-
2184
- # Add -p flag and stream-json output for background mode if not already present
2185
- claude_args = forwarded
2186
- if background and '-p' not in claude_args and '--print' not in claude_args:
2187
- claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
2188
-
2189
- terminal_mode = get_config().terminal
2190
-
2191
- # Calculate total instances to launch
2192
- total_instances = count * len(agents)
2193
-
2194
- # Fail fast for here mode with multiple instances
2195
- if terminal_mode == 'here' and total_instances > 1:
2196
- print(format_error(
2197
- f"'here' mode cannot launch {total_instances} instances (it's one terminal window)",
2198
- "Use 'hcom 1' for one generic instance"
2199
- ), file=sys.stderr)
2200
- return 1
2201
-
2202
- log_file = hcom_path(LOG_FILE)
2203
- instances_dir = hcom_path(INSTANCES_DIR)
2204
-
2205
- if not log_file.exists():
2206
- log_file.touch()
2207
-
2208
- # Build environment variables for Claude instances
2209
- base_env = build_claude_env()
2210
-
2211
- # Add tag-specific hints if provided
2212
- if tag:
2213
- base_env['HCOM_TAG'] = tag
2214
-
2215
- launched = 0
2216
- initial_prompt = get_config().prompt
2217
-
2218
- # Launch count instances of each agent
2219
- for agent in agents:
2220
- for _ in range(count):
2221
- instance_type = agent
2222
- instance_env = base_env.copy()
2223
-
2224
- # Mark all hcom-launched instances
2225
- instance_env['HCOM_LAUNCHED'] = '1'
2226
-
2227
- # Mark background instances via environment with log filename
2228
- if background:
2229
- # Generate unique log filename
2230
- log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
2231
- instance_env['HCOM_BACKGROUND'] = log_filename
2232
-
2233
- # Build claude command
2234
- if not instance_type:
2235
- # No agent - no agent content
2236
- claude_cmd, _ = build_claude_command(
2237
- agent_content=None,
2238
- claude_args=claude_args,
2239
- initial_prompt=initial_prompt
2240
- )
2241
- else:
2242
- # Agent instance
2243
- try:
2244
- agent_content, agent_config = resolve_agent(instance_type)
2245
- # Mark this as a subagent instance for SessionStart hook
2246
- instance_env['HCOM_SUBAGENT_TYPE'] = instance_type
2247
- # Prepend agent instance awareness to system prompt
2248
- agent_prefix = f"You are an instance of {instance_type}. Do not start a subagent with {instance_type} unless explicitly asked.\n\n"
2249
- agent_content = agent_prefix + agent_content
2250
- # Use agent's model and tools if specified and not overridden in claude_args
2251
- agent_model = agent_config.get('model')
2252
- agent_tools = agent_config.get('tools')
2253
- claude_cmd, _ = build_claude_command(
2254
- agent_content=agent_content,
2255
- claude_args=claude_args,
2256
- initial_prompt=initial_prompt,
2257
- model=agent_model,
2258
- tools=agent_tools
2259
- )
2260
- # Agent temp files live under ~/.hcom/scripts/ for unified housekeeping cleanup
2261
- except (FileNotFoundError, ValueError) as e:
2262
- print(str(e), file=sys.stderr)
2263
- continue
2264
-
2265
- try:
2266
- if background:
2267
- log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
2268
- if log_file:
2269
- print(f"Background instance launched, log: {log_file}")
2270
- launched += 1
2271
- else:
2272
- if launch_terminal(claude_cmd, instance_env, cwd=os.getcwd()):
2273
- launched += 1
2274
- except Exception as e:
2275
- print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
2276
-
2277
- requested = total_instances
2278
- failed = requested - launched
2279
-
2280
- if launched == 0:
2281
- print(format_error(f"No instances launched (0/{requested})"), file=sys.stderr)
2282
- return 1
2283
-
2284
- # Show results
2285
- if failed > 0:
2286
- print(f"Launched {launched}/{requested} Claude instance{'s' if requested != 1 else ''} ({failed} failed)")
2287
- else:
2288
- print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
2289
-
2290
- # Auto-launch watch dashboard if in new window mode (new or custom) and all instances launched successfully
2291
- terminal_mode = get_config().terminal
2292
-
2293
- # Only auto-watch if ALL instances launched successfully and launches windows (not 'here' or 'print')
2294
- if terminal_mode not in ('here', 'print') and failed == 0 and is_interactive():
2295
- # Show tips first if needed
2296
- if tag:
2297
- print(f"\n • Send to {tag} team: hcom send '@{tag} message'")
2298
-
2299
- # Clear transition message
2300
- print("\nOpening hcom watch...")
2301
- time.sleep(2) # Brief pause so user sees the message
2302
-
2303
- # Launch interactive watch dashboard in current terminal
2304
- return cmd_watch([]) # Empty argv = interactive mode
2305
- else:
2306
- tips = [
2307
- "Run 'hcom watch' to view/send in conversation dashboard",
2308
- ]
2309
- if tag:
2310
- tips.append(f"Send to {tag} team: hcom send '@{tag} message'")
2311
-
2312
- if tips:
2313
- print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
2314
-
2315
- return 0
2316
-
2317
- except ValueError as e:
2318
- print(str(e), file=sys.stderr)
2319
- return 1
2320
- except Exception as e:
2321
- print(str(e), file=sys.stderr)
2322
- return 1
2323
-
2324
- def cmd_watch(argv: list[str]) -> int:
2325
- """View conversation dashboard: hcom watch [--logs|--status|--wait [SEC]]"""
2326
- # Extract launch flag for external terminals (used by claude code bootstrap)
2327
- cleaned_args: list[str] = []
2328
- for arg in argv:
2329
- if arg == '--launch':
2330
- watch_cmd = f"{build_hcom_command()} watch"
2331
- result = launch_terminal(watch_cmd, build_claude_env(), cwd=os.getcwd())
2332
- return 0 if result else 1
2333
- else:
2334
- cleaned_args.append(arg)
2335
- argv = cleaned_args
2336
-
2337
- # Parse arguments
2338
- show_logs = '--logs' in argv
2339
- show_status = '--status' in argv
2340
- wait_timeout = None
2341
-
2342
- # Check for --wait flag
2343
- if '--wait' in argv:
2344
- idx = argv.index('--wait')
2345
- if idx + 1 < len(argv):
2346
- try:
2347
- wait_timeout = int(argv[idx + 1])
2348
- if wait_timeout < 0:
2349
- raise CLIError('--wait expects a non-negative number of seconds.')
2350
- except ValueError:
2351
- wait_timeout = 60 # Default for non-numeric values
2352
- else:
2353
- wait_timeout = 60 # Default timeout
2354
- show_logs = True # --wait implies logs mode
2355
-
2356
- log_file = hcom_path(LOG_FILE)
2357
- instances_dir = hcom_path(INSTANCES_DIR)
2358
-
2359
- if not log_file.exists() and not instances_dir.exists():
2360
- print(format_error("No conversation log found", "Run 'hcom' first"), file=sys.stderr)
2361
- return 1
2362
-
2363
- # Non-interactive mode (no TTY or flags specified)
2364
- if not is_interactive() or show_logs or show_status:
2365
- if show_logs:
2366
- # Atomic position capture BEFORE parsing (prevents race condition)
2367
- if log_file.exists():
2368
- last_pos = log_file.stat().st_size # Capture position first
2369
- messages = parse_log_messages(log_file).messages
2370
- else:
2371
- last_pos = 0
2372
- messages = []
2373
-
2374
- # If --wait, show recent messages (max of: last 3 messages OR all messages in last 5 seconds)
2375
- if wait_timeout is not None:
2376
- cutoff = datetime.now() - timedelta(seconds=5)
2377
- recent_by_time = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
2378
- last_three = messages[-3:] if len(messages) >= 3 else messages
2379
- # Show whichever is larger: recent by time or last 3
2380
- recent_messages = recent_by_time if len(recent_by_time) > len(last_three) else last_three
2381
- # Status to stderr, data to stdout
2382
- if recent_messages:
2383
- print(f'---Showing recent messages---', file=sys.stderr)
2384
- for msg in recent_messages:
2385
- print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
2386
- print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
2387
- else:
2388
- print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
2389
-
2390
-
2391
- # Wait loop
2392
- start_time = time.time()
2393
- while time.time() - start_time < wait_timeout:
2394
- if log_file.exists():
2395
- current_size = log_file.stat().st_size
2396
- new_messages = []
2397
- if current_size > last_pos:
2398
- # Capture new position BEFORE parsing (atomic)
2399
- new_messages = parse_log_messages(log_file, last_pos).messages
2400
- if new_messages:
2401
- for msg in new_messages:
2402
- print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
2403
- last_pos = current_size # Update only after successful processing
2404
- return 0 # Success - got new messages
2405
- if current_size > last_pos:
2406
- last_pos = current_size # Update even if no messages (file grew but no complete messages yet)
2407
- time.sleep(0.1)
2408
-
2409
- # Timeout message to stderr
2410
- print(f'[TIMED OUT] No new messages received after {wait_timeout} seconds.', file=sys.stderr)
2411
- return 1 # Timeout - no new messages
2412
-
2413
- # Regular --logs (no --wait): print all messages to stdout
2414
- else:
2415
- if messages:
2416
- for msg in messages:
2417
- print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
2418
- else:
2419
- print("No messages yet", file=sys.stderr)
2420
-
2421
-
2422
- elif show_status:
2423
- # Build JSON output
2424
- positions = load_all_positions()
2425
-
2426
- instances = {}
2427
- status_counts = {}
2428
-
2429
- for name, data in positions.items():
2430
- if not should_show_in_watch(data):
2431
- continue
2432
- status, age, _ = get_instance_status(data)
2433
- instances[name] = {
2434
- "status": status,
2435
- "age": age.strip() if age else "",
2436
- "directory": data.get("directory", "unknown"),
2437
- "session_id": data.get("session_id", ""),
2438
- "last_status": data.get("last_status", ""),
2439
- "last_status_time": data.get("last_status_time", 0),
2440
- "last_status_context": data.get("last_status_context", ""),
2441
- "background": bool(data.get("background"))
2442
- }
2443
- status_counts[status] = status_counts.get(status, 0) + 1
2444
-
2445
- # Get recent messages
2446
- messages = []
2447
- if log_file.exists():
2448
- all_messages = parse_log_messages(log_file).messages
2449
- messages = all_messages[-5:] if all_messages else []
2450
-
2451
- # Output JSON
2452
- output = {
2453
- "instances": instances,
2454
- "recent_messages": messages,
2455
- "status_summary": status_counts,
2456
- "log_file": str(log_file),
2457
- "timestamp": datetime.now().isoformat()
2458
- }
2459
-
2460
- print(json.dumps(output, indent=2))
2461
- else:
2462
- print("No TTY - Automation usage:", file=sys.stderr)
2463
- print(" hcom watch --logs Show message history", file=sys.stderr)
2464
- print(" hcom watch --status Show instance status", file=sys.stderr)
2465
- print(" hcom watch --wait Wait for new messages", file=sys.stderr)
2466
- print(" hcom watch --launch Launch interactive dashboard in new terminal", file=sys.stderr)
2467
- print(" Full information: hcom --help")
2468
-
2469
- return 0
2470
-
2471
- # Interactive dashboard mode
2472
- status_suffix = f"{DIM} [⏎]...{RESET}"
2473
-
2474
- # Atomic position capture BEFORE showing messages (prevents race condition)
2475
- if log_file.exists():
2476
- last_pos = log_file.stat().st_size
2477
- else:
2478
- last_pos = 0
2479
-
2480
- all_messages = show_main_screen_header()
2481
-
2482
- show_recent_messages(all_messages, limit=5)
2483
- print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
2484
-
2485
- # Print newline to ensure status starts on its own line
2486
- print()
2487
-
2488
- current_status = get_status_summary()
2489
- update_status(f"{current_status}{status_suffix}")
2490
- last_status_update = time.time()
2491
-
2492
- last_status = current_status
2493
-
2494
- try:
2495
- while True:
2496
- now = time.time()
2497
- if now - last_status_update > 0.1: # 100ms
2498
- current_status = get_status_summary()
2499
-
2500
- # Only redraw if status text changed
2501
- if current_status != last_status:
2502
- update_status(f"{current_status}{status_suffix}")
2503
- last_status = current_status
2504
-
2505
- last_status_update = now
2506
-
2507
- if log_file.exists():
2508
- current_size = log_file.stat().st_size
2509
- if current_size > last_pos:
2510
- new_messages = parse_log_messages(log_file, last_pos).messages
2511
- # Use the last known status for consistency
2512
- status_line_text = f"{last_status}{status_suffix}"
2513
- for msg in new_messages:
2514
- log_line_with_status(format_message_line(msg), status_line_text)
2515
- last_pos = current_size
2516
-
2517
- # Check for keyboard input
2518
- ready_for_input = False
2519
- if IS_WINDOWS:
2520
- import msvcrt # type: ignore[import]
2521
- if msvcrt.kbhit(): # type: ignore[attr-defined]
2522
- msvcrt.getch() # type: ignore[attr-defined]
2523
- ready_for_input = True
2524
- else:
2525
- if select.select([sys.stdin], [], [], 0.1)[0]:
2526
- sys.stdin.readline()
2527
- ready_for_input = True
2528
-
2529
- if ready_for_input:
2530
- sys.stdout.write("\r\033[K")
2531
-
2532
- message = alt_screen_detailed_status_and_input()
2533
-
2534
- all_messages = show_main_screen_header()
2535
- show_recent_messages(all_messages)
2536
- print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
2537
- print(f"{DIM}{'─' * 40}{RESET}")
2538
-
2539
- if log_file.exists():
2540
- last_pos = log_file.stat().st_size
2541
-
2542
- if message and message.strip():
2543
- send_cli(message.strip(), quiet=True)
2544
- print(f"{FG_GREEN}✓ Sent{RESET}")
2545
-
2546
- print()
2547
-
2548
- current_status = get_status_summary()
2549
- update_status(f"{current_status}{status_suffix}")
2550
-
2551
- time.sleep(0.1)
2552
-
2553
- except KeyboardInterrupt:
2554
- sys.stdout.write("\033[?1049l\r\033[K")
2555
- print(f"\n{DIM}[stopped]{RESET}")
2556
-
2557
- return 0
2558
-
2559
- def clear() -> int:
2560
- """Clear and archive conversation"""
2561
- log_file = hcom_path(LOG_FILE)
2562
- instances_dir = hcom_path(INSTANCES_DIR)
2563
- archive_folder = hcom_path(ARCHIVE_DIR)
2564
-
2565
- # cleanup: temp files, old scripts, old outbox files
2566
- cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
2567
- if instances_dir.exists():
2568
- sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
2569
-
2570
- scripts_dir = hcom_path(SCRIPTS_DIR)
2571
- if scripts_dir.exists():
2572
- 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)
2573
-
2574
- # Check if hcom files exist
2575
- if not log_file.exists() and not instances_dir.exists():
2576
- print("No HCOM conversation to clear")
2577
- return 0
2578
-
2579
- # Archive existing files if they have content
2580
- timestamp = get_archive_timestamp()
2581
- archived = False
2582
-
2583
- try:
2584
- has_log = log_file.exists() and log_file.stat().st_size > 0
2585
- has_instances = instances_dir.exists() and any(instances_dir.glob('*.json'))
2586
-
2587
- if has_log or has_instances:
2588
- # Create session archive folder with timestamp
2589
- session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
2590
- session_archive.mkdir(parents=True, exist_ok=True)
2591
-
2592
- # Archive log file
2593
- if has_log:
2594
- archive_log = session_archive / LOG_FILE
2595
- log_file.rename(archive_log)
2596
- archived = True
2597
- elif log_file.exists():
2598
- log_file.unlink()
2599
-
2600
- # Archive instances
2601
- if has_instances:
2602
- archive_instances = session_archive / INSTANCES_DIR
2603
- archive_instances.mkdir(parents=True, exist_ok=True)
2604
-
2605
- # Move json files only
2606
- for f in instances_dir.glob('*.json'):
2607
- f.rename(archive_instances / f.name)
2608
-
2609
- archived = True
2610
- else:
2611
- # Clean up empty files/dirs
2612
- if log_file.exists():
2613
- log_file.unlink()
2614
- if instances_dir.exists():
2615
- shutil.rmtree(instances_dir)
2616
-
2617
- log_file.touch()
2618
- clear_all_positions()
2619
-
2620
- if archived:
2621
- print(f"Archived to archive/session-{timestamp}/")
2622
- print("Started fresh HCOM conversation log")
2623
- return 0
2624
-
2625
- except Exception as e:
2626
- print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
2627
- return 1
2628
-
2629
- def remove_global_hooks() -> bool:
2630
- """Remove HCOM hooks from ~/.claude/settings.json
2631
- Returns True on success, False on failure."""
2632
- settings_path = get_claude_settings_path()
2633
-
2634
- if not settings_path.exists():
2635
- return True # No settings = no hooks to remove
2636
-
2637
- try:
2638
- settings = load_settings_json(settings_path, default=None)
2639
- if not settings:
2640
- return False
2641
-
2642
- _remove_hcom_hooks_from_settings(settings)
2643
- atomic_write(settings_path, json.dumps(settings, indent=2))
2644
- return True
2645
- except Exception:
2646
- return False
2647
-
2648
- def cleanup_directory_hooks(directory: Path | str) -> tuple[int, str]:
2649
- """Remove hcom hooks from a specific directory
2650
- Returns tuple: (exit_code, message)
2651
- exit_code: 0 for success, 1 for error
2652
- message: what happened
2653
- """
2654
- settings_path = Path(directory) / '.claude' / 'settings.local.json'
2655
-
2656
- if not settings_path.exists():
2657
- return 0, "No Claude settings found"
2658
-
2659
- try:
2660
- # Load existing settings
2661
- settings = load_settings_json(settings_path, default=None)
2662
- if not settings:
2663
- return 1, "Cannot read Claude settings"
2664
-
2665
- hooks_found = False
2666
-
2667
- # Include PostToolUse for backward compatibility cleanup
2668
- original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2669
- for event in LEGACY_HOOK_TYPES)
2670
-
2671
- _remove_hcom_hooks_from_settings(settings)
2672
-
2673
- # Check if any were removed
2674
- new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2675
- for event in LEGACY_HOOK_TYPES)
2676
- if new_hook_count < original_hook_count:
2677
- hooks_found = True
2678
-
2679
- if not hooks_found:
2680
- return 0, "No hcom hooks found"
2681
-
2682
- # Write back or delete settings
2683
- if not settings or (len(settings) == 0):
2684
- # Delete empty settings file
2685
- settings_path.unlink()
2686
- return 0, "Removed hcom hooks (settings file deleted)"
2687
- else:
2688
- # Write updated settings
2689
- atomic_write(settings_path, json.dumps(settings, indent=2))
2690
- return 0, "Removed hcom hooks from settings"
2691
-
2692
- except json.JSONDecodeError:
2693
- return 1, format_error("Corrupted settings.local.json file")
2694
- except Exception as e:
2695
- return 1, format_error(f"Cannot modify settings.local.json: {e}")
2696
-
2697
-
2698
- def cmd_stop(argv: list[str]) -> int:
2699
- """Stop instances: hcom stop [alias|all] [--force] [--_hcom_session ID]"""
2700
- # Parse arguments
2701
- target = None
2702
- force = '--force' in argv
2703
- session_id = None
2704
-
2705
- # Extract --_hcom_session if present
2706
- if '--_hcom_session' in argv:
2707
- idx = argv.index('--_hcom_session')
2708
- if idx + 1 < len(argv):
2709
- session_id = argv[idx + 1]
2710
- argv = argv[:idx] + argv[idx + 2:]
2711
-
2712
- # Remove flags to get target
2713
- args_without_flags = [a for a in argv if not a.startswith('--')]
2714
- if args_without_flags:
2715
- target = args_without_flags[0]
2716
-
2717
- # Handle 'all' target
2718
- if target == 'all':
2719
- positions = load_all_positions()
2720
-
2721
- if not positions:
2722
- print("No instances found")
2723
- return 0
2724
-
2725
- stopped_count = 0
2726
- bg_logs = []
2727
- stopped_names = []
2728
- for instance_name, instance_data in positions.items():
2729
- if instance_data.get('enabled', False):
2730
- disable_instance(instance_name)
2731
- stopped_names.append(instance_name)
2732
- stopped_count += 1
2733
-
2734
- # Track background logs
2735
- if instance_data.get('background'):
2736
- log_file = instance_data.get('background_log_file', '')
2737
- if log_file:
2738
- bg_logs.append((instance_name, log_file))
2739
-
2740
- if stopped_count == 0:
2741
- print("No instances to stop")
2742
- else:
2743
- print(f"Stopped {stopped_count} instance(s): {', '.join(stopped_names)}")
2744
-
2745
- # Show background logs if any
2746
- if bg_logs:
2747
- print()
2748
- print("Background instance logs:")
2749
- for name, log_file in bg_logs:
2750
- print(f" {name}: {log_file}")
2751
-
2752
- return 0
2753
-
2754
- # Stop specific instance or self
2755
- # Get instance name from injected session or target
2756
- if session_id and not target:
2757
- instance_name, _ = resolve_instance_name(session_id, get_config().tag)
2758
- else:
2759
- instance_name = target
2760
-
2761
- position = load_instance_position(instance_name) if instance_name else None
2762
-
2763
- if not instance_name:
2764
- if os.environ.get('CLAUDECODE') == '1':
2765
- print("Error: Cannot determine instance", file=sys.stderr)
2766
- print("Usage: Prompt Claude to run 'hcom stop' (or directly use: hcom stop <alias> or hcom stop all)", file=sys.stderr)
2767
- else:
2768
- print("Error: Alias required", file=sys.stderr)
2769
- print("Usage: hcom stop <alias>", file=sys.stderr)
2770
- print(" Or: hcom stop all", file=sys.stderr)
2771
- print(" Or: prompt claude to run 'hcom stop' on itself", file=sys.stderr)
2772
- positions = load_all_positions()
2773
- visible = [alias for alias, data in positions.items() if should_show_in_watch(data)]
2774
- if visible:
2775
- print(f"Active aliases: {', '.join(sorted(visible))}", file=sys.stderr)
2776
- return 1
2777
-
2778
- if not position:
2779
- print(f"No instance found for {instance_name}")
2780
- return 1
2781
-
2782
- # Skip already stopped instances (unless forcing)
2783
- if not position.get('enabled', False) and not force:
2784
- print(f"HCOM already stopped for {instance_name}")
2785
- return 0
2786
-
2787
- # Disable instance (optionally with force)
2788
- disable_instance(instance_name, force=force)
2789
-
2790
- if force:
2791
- print(f"⚠️ Force stopped HCOM for {instance_name}.")
2792
- print(f" Bash tool use is now DENIED.")
2793
- print(f" To restart: hcom start {instance_name}")
2794
- else:
2795
- print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
2796
-
2797
- # Show background log location if applicable
2798
- if position.get('background'):
2799
- log_file = position.get('background_log_file', '')
2800
- if log_file:
2801
- print(f"\nBackground log: {log_file}")
2802
- print(f"Monitor: tail -f {log_file}")
2803
- if not force:
2804
- print(f"Force stop: hcom stop --force {instance_name}")
2805
-
2806
- return 0
2807
-
2808
- def cmd_start(argv: list[str]) -> int:
2809
- """Enable HCOM participation: hcom start [alias] [--_hcom_session ID]"""
2810
- # Parse arguments
2811
- target = None
2812
- session_id = None
2813
-
2814
- # Extract --_hcom_session if present
2815
- if '--_hcom_session' in argv:
2816
- idx = argv.index('--_hcom_session')
2817
- if idx + 1 < len(argv):
2818
- session_id = argv[idx + 1]
2819
- argv = argv[:idx] + argv[idx + 2:]
2820
-
2821
- # Remove flags to get target
2822
- args_without_flags = [a for a in argv if not a.startswith('--')]
2823
- if args_without_flags:
2824
- target = args_without_flags[0]
2825
-
2826
- # Get instance name from injected session or target
2827
- if session_id and not target:
2828
- instance_name, existing_data = resolve_instance_name(session_id, get_config().tag)
2829
-
2830
- # Create instance if it doesn't exist (opt-in for vanilla instances)
2831
- if not existing_data:
2832
- initialize_instance_in_position_file(instance_name, session_id)
2833
- # Enable instance (clears all stop flags)
2834
- enable_instance(instance_name)
2835
- print(f"\nStarted HCOM for {instance_name}")
2836
- else:
2837
- # Skip already started instances
2838
- if existing_data.get('enabled', False):
2839
- print(f"HCOM already started for {instance_name}")
2840
- return 0
2841
-
2842
- # Check if background instance has exited permanently
2843
- if existing_data.get('session_ended') and existing_data.get('background'):
2844
- session = existing_data.get('session_id', '')
2845
- print(f"Cannot start {instance_name}: background instance has exited permanently")
2846
- print(f"Background instances terminate when stopped and cannot be restarted")
2847
- if session:
2848
- print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
2849
- return 1
2850
-
2851
- # Re-enabling existing instance
2852
- enable_instance(instance_name)
2853
- print(f"Started HCOM for {instance_name}")
2854
-
2855
- return 0
2856
-
2857
- # CLI path: start specific instance
2858
- positions = load_all_positions()
2859
-
2860
- # Handle missing target from external CLI
2861
- if not target:
2862
- if os.environ.get('CLAUDECODE') == '1':
2863
- print("Error: Cannot determine instance", file=sys.stderr)
2864
- print("Usage: Prompt Claude to run 'hcom start' (or: hcom start <alias>)", file=sys.stderr)
2865
- else:
2866
- print("Error: Alias required", file=sys.stderr)
2867
- print("Usage: hcom start <alias> (or: prompt claude to run 'hcom start')", file=sys.stderr)
2868
- print("To launch new instances: hcom <count>", file=sys.stderr)
2869
- return 1
2870
-
2871
- # Start specific instance
2872
- instance_name = target
2873
- position = positions.get(instance_name)
2874
-
2875
- if not position:
2876
- print(f"Instance not found: {instance_name}")
2877
- return 1
2878
-
2879
- # Skip already started instances
2880
- if position.get('enabled', False):
2881
- print(f"HCOM already started for {instance_name}")
2882
- return 0
2883
-
2884
- # Check if background instance has exited permanently
2885
- if position.get('session_ended') and position.get('background'):
2886
- session = position.get('session_id', '')
2887
- print(f"Cannot start {instance_name}: background instance has exited permanently")
2888
- print(f"Background instances terminate when stopped and cannot be restarted")
2889
- if session:
2890
- print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
2891
- return 1
2892
-
2893
- # Enable instance (clears all stop flags)
2894
- enable_instance(instance_name)
2895
-
2896
- print(f"Started HCOM for {instance_name}. Rejoined chat.")
2897
- return 0
2898
-
2899
- def cmd_reset(argv: list[str]) -> int:
2900
- """Reset HCOM components: logs, hooks, config
2901
- Usage:
2902
- hcom reset # Everything (stop all + logs + hooks + config)
2903
- hcom reset logs # Archive conversation only
2904
- hcom reset hooks # Remove hooks only
2905
- hcom reset config # Clear config (backup to config.env.TIMESTAMP)
2906
- hcom reset logs hooks # Combine targets
2907
- """
2908
- # No args = everything
2909
- do_everything = not argv
2910
- targets = argv if argv else ['logs', 'hooks', 'config']
2911
-
2912
- # Validate targets
2913
- valid = {'logs', 'hooks', 'config'}
2914
- invalid = [t for t in targets if t not in valid]
2915
- if invalid:
2916
- print(f"Invalid target(s): {', '.join(invalid)}", file=sys.stderr)
2917
- print("Valid targets: logs, hooks, config", file=sys.stderr)
2918
- return 1
2919
-
2920
- exit_codes = []
2921
-
2922
- # Stop all instances if doing everything
2923
- if do_everything:
2924
- exit_codes.append(cmd_stop(['all']))
2925
-
2926
- # Execute based on targets
2927
- if 'logs' in targets:
2928
- exit_codes.append(clear())
2929
-
2930
- if 'hooks' in targets:
2931
- exit_codes.append(cleanup('--all'))
2932
- if remove_global_hooks():
2933
- print("Removed hooks")
2934
- else:
2935
- print("Warning: Could not remove hooks. Check your claude settings.json file it might be invalid", file=sys.stderr)
2936
- exit_codes.append(1)
2937
-
2938
- if 'config' in targets:
2939
- config_path = hcom_path(CONFIG_FILE)
2940
- if config_path.exists():
2941
- # Backup with timestamp
2942
- timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
2943
- backup_path = hcom_path(f'config.env.{timestamp}')
2944
- shutil.copy2(config_path, backup_path)
2945
- config_path.unlink()
2946
- print(f"Config backed up to config.env.{timestamp} and cleared")
2947
- exit_codes.append(0)
2948
- else:
2949
- print("No config file to clear")
2950
- exit_codes.append(0)
2951
-
2952
- return max(exit_codes) if exit_codes else 0
2953
-
2954
- def cleanup(*args: str) -> int:
2955
- """Remove hcom hooks from current directory or all directories"""
2956
- if args and args[0] == '--all':
2957
- directories = set()
2958
-
2959
- # Get all directories from current instances
2960
- try:
2961
- positions = load_all_positions()
2962
- if positions:
2963
- for instance_data in positions.values():
2964
- if isinstance(instance_data, dict) and 'directory' in instance_data:
2965
- directories.add(instance_data['directory'])
2966
- except Exception as e:
2967
- print(f"Warning: Could not read current instances: {e}")
2968
-
2969
- # Also check archived instances for directories (until 0.5.0)
2970
- try:
2971
- archive_dir = hcom_path(ARCHIVE_DIR)
2972
- if archive_dir.exists():
2973
- for session_dir in archive_dir.iterdir():
2974
- if session_dir.is_dir() and session_dir.name.startswith('session-'):
2975
- instances_dir = session_dir / 'instances'
2976
- if instances_dir.exists():
2977
- for instance_file in instances_dir.glob('*.json'):
2978
- try:
2979
- data = json.loads(instance_file.read_text())
2980
- if 'directory' in data:
2981
- directories.add(data['directory'])
2982
- except Exception:
2983
- pass
2984
- except Exception as e:
2985
- print(f"Warning: Could not read archived instances: {e}")
2986
-
2987
- if not directories:
2988
- print("No directories found in current HCOM tracking")
2989
- return 0
2990
-
2991
- print(f"Found {len(directories)} unique directories to check")
2992
- cleaned = 0
2993
- failed = 0
2994
- already_clean = 0
2995
-
2996
- for directory in sorted(directories):
2997
- # Check if directory exists
2998
- if not Path(directory).exists():
2999
- print(f"\nSkipping {directory} (directory no longer exists)")
3000
- continue
3001
-
3002
- print(f"\nChecking {directory}...")
3003
-
3004
- exit_code, message = cleanup_directory_hooks(Path(directory))
3005
- if exit_code == 0:
3006
- if "No hcom hooks found" in message or "No Claude settings found" in message:
3007
- already_clean += 1
3008
- print(f" {message}")
3009
- else:
3010
- cleaned += 1
3011
- print(f" {message}")
3012
- else:
3013
- failed += 1
3014
- print(f" {message}")
3015
-
3016
- print(f"\nSummary:")
3017
- print(f" Cleaned: {cleaned} directories")
3018
- print(f" Already clean: {already_clean} directories")
3019
- if failed > 0:
3020
- print(f" Failed: {failed} directories")
3021
- return 1
3022
- return 0
3023
-
3024
- else:
3025
- exit_code, message = cleanup_directory_hooks(Path.cwd())
3026
- print(message)
3027
- return exit_code
3028
-
3029
- def ensure_hooks_current() -> bool:
3030
- """Ensure hooks match current execution context - called on EVERY command.
3031
- Auto-updates hooks if execution context changes (e.g., pip → uvx).
3032
- Always returns True (warns but never blocks - Claude Code is fault-tolerant)."""
3033
-
3034
- # Verify hooks exist and match current execution context
3035
- global_settings = get_claude_settings_path()
3036
-
3037
- # Check if hooks are valid (exist + env var matches current context)
3038
- hooks_exist = verify_hooks_installed(global_settings)
3039
- env_var_matches = False
3040
-
3041
- if hooks_exist:
3042
- try:
3043
- settings = load_settings_json(global_settings, default={})
3044
- if settings is None:
3045
- settings = {}
3046
- current_hcom = _build_hcom_env_value()
3047
- installed_hcom = settings.get('env', {}).get('HCOM')
3048
- env_var_matches = (installed_hcom == current_hcom)
3049
- except Exception:
3050
- # Failed to read settings - try to fix by updating
3051
- env_var_matches = False
3052
-
3053
- # Install/update hooks if missing or env var wrong
3054
- if not hooks_exist or not env_var_matches:
3055
- try:
3056
- setup_hooks()
3057
- if os.environ.get('CLAUDECODE') == '1':
3058
- print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
3059
- print("=" * 60, file=sys.stderr)
3060
- except Exception as e:
3061
- # Failed to verify/update hooks, but they might still work
3062
- # Claude Code is fault-tolerant with malformed JSON
3063
- print(f"⚠️ Could not verify/update hooks: {e}", file=sys.stderr)
3064
- print("If HCOM doesn't work, check ~/.claude/settings.json", file=sys.stderr)
3065
-
3066
- return True
3067
-
3068
- def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> int:
3069
- """Send message to hcom: hcom send "message" [--_hcom_session ID]"""
3070
- # Parse message and session_id
3071
- message = None
3072
- session_id = None
3073
-
3074
- # Extract --_hcom_session if present (injected by PreToolUse hook)
3075
- if '--_hcom_session' in argv:
3076
- idx = argv.index('--_hcom_session')
3077
- if idx + 1 < len(argv):
3078
- session_id = argv[idx + 1]
3079
- argv = argv[:idx] + argv[idx + 2:] # Remove flag and value
3080
-
3081
- # First non-flag argument is the message
3082
- if argv:
3083
- message = argv[0]
3084
-
3085
- # Check message is provided
3086
- if not message:
3087
- print(format_error("No message provided"), file=sys.stderr)
3088
- return 1
3089
-
3090
- # Check if hcom files exist
3091
- log_file = hcom_path(LOG_FILE)
3092
- instances_dir = hcom_path(INSTANCES_DIR)
3093
-
3094
- if not log_file.exists() and not instances_dir.exists():
3095
- print(format_error("No conversation found", "Run 'hcom <count>' first"), file=sys.stderr)
3096
- return 1
3097
-
3098
- # Validate message
3099
- error = validate_message(message)
3100
- if error:
3101
- print(error, file=sys.stderr)
3102
- return 1
3103
-
3104
- # Check for unmatched mentions (minimal warning)
3105
- mentions = MENTION_PATTERN.findall(message)
3106
- if mentions:
3107
- try:
3108
- positions = load_all_positions()
3109
- all_instances = list(positions.keys())
3110
- sender_name = SENDER
3111
- all_names = all_instances + [sender_name]
3112
- unmatched = [m for m in mentions
3113
- if not any(name.lower().startswith(m.lower()) for name in all_names)]
3114
- if unmatched:
3115
- print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
3116
- except Exception:
3117
- pass # Don't fail on warning
3118
-
3119
- # Determine sender from injected session_id or CLI
3120
- if session_id and not force_cli:
3121
- # Instance context - resolve name from session_id (searches existing instances first)
3122
- try:
3123
- sender_name, instance_data = resolve_instance_name(session_id, get_config().tag)
3124
- except (ValueError, Exception) as e:
3125
- print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
3126
- return 1
3127
-
3128
- # Initialize instance if doesn't exist (first use)
3129
- if not instance_data:
3130
- initialize_instance_in_position_file(sender_name, session_id)
3131
- instance_data = load_instance_position(sender_name)
3132
-
3133
- # Check force_closed
3134
- if instance_data.get('force_closed'):
3135
- print(format_error(f"HCOM force stopped for this instance. To recover, delete instance file: rm ~/.hcom/instances/{sender_name}.json"), file=sys.stderr)
3136
- return 1
3137
-
3138
- # Check enabled state
3139
- if not instance_data.get('enabled', False):
3140
- print(format_error("HCOM not started for this instance. To send a message first run: 'hcom start' then use hcom send"), file=sys.stderr)
3141
- return 1
3142
-
3143
- # Send message
3144
- if not send_message(sender_name, message):
3145
- print(format_error("Failed to send message"), file=sys.stderr)
3146
- return 1
3147
-
3148
- # Show unread messages
3149
- messages = get_unread_messages(sender_name, update_position=True)
3150
- if messages:
3151
- max_msgs = MAX_MESSAGES_PER_DELIVERY
3152
- formatted = format_hook_messages(messages[:max_msgs], sender_name)
3153
- print(f"Message sent\n\n{formatted}", file=sys.stderr)
3154
- else:
3155
- print("Message sent", file=sys.stderr)
3156
-
3157
- return 0
3158
- else:
3159
- # CLI context - no session_id or force_cli=True
3160
-
3161
- # Warn if inside Claude Code but no session_id (hooks not working)
3162
- if os.environ.get('CLAUDECODE') == '1' and not session_id and not force_cli:
3163
- print(f"⚠️ Cannot determine alias - message sent as '{SENDER}'", file=sys.stderr)
3164
- print(" Prompt Claude to send a hcom message instead of using bash mode (! prefix).", file=sys.stderr)
3165
-
3166
-
3167
- sender_name = SENDER
3168
-
3169
- if not send_message(sender_name, message):
3170
- print(format_error("Failed to send message"), file=sys.stderr)
3171
- return 1
3172
-
3173
- if not quiet:
3174
- print(f"✓ Sent from {sender_name}", file=sys.stderr)
3175
-
3176
- return 0
3177
-
3178
- def send_cli(message: str, quiet: bool = False) -> int:
3179
- """Force CLI sender (skip outbox, use config sender name)"""
3180
- return cmd_send([message], force_cli=True, quiet=quiet)
3181
-
3182
- # ==================== Hook Helpers ====================
3183
-
3184
- def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
3185
- """Format messages for hook feedback"""
3186
- if len(messages) == 1:
3187
- msg = messages[0]
3188
- reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
3189
- else:
3190
- parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
3191
- reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
3192
-
3193
- # Only append hints to messages
3194
- hints = get_config().hints
3195
- if hints:
3196
- reason = f"{reason} | [{hints}]"
3197
-
3198
- return reason
3199
-
3200
- # ==================== Hook Handlers ====================
3201
-
3202
- def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -> tuple[str, dict[str, Any], bool]:
3203
- """
3204
- Initialize instance context. Flow:
3205
- 1. Resolve instance name (search by session_id, generate if not found)
3206
- 2. Create instance file if fresh start in UserPromptSubmit
3207
- 3. Build updates dict
3208
- 4. Return (instance_name, updates, is_matched_resume)
3209
- """
3210
- session_id = hook_data.get('session_id', '')
3211
- transcript_path = hook_data.get('transcript_path', '')
3212
- tag = get_config().tag
3213
-
3214
- # Resolve instance name - existing_data is None for fresh starts
3215
- instance_name, existing_data = resolve_instance_name(session_id, tag)
3216
-
3217
- # Save migrated data if we have it
3218
- if existing_data:
3219
- save_instance_position(instance_name, existing_data)
3220
-
3221
- # Create instance file if fresh start in UserPromptSubmit
3222
- if existing_data is None and hook_type == 'userpromptsubmit':
3223
- initialize_instance_in_position_file(instance_name, session_id)
3224
-
3225
- # Build updates dict
3226
- updates: dict[str, Any] = {
3227
- 'directory': str(Path.cwd()),
3228
- 'tag': tag,
3229
- }
3230
-
3231
- if session_id:
3232
- updates['session_id'] = session_id
3233
-
3234
- if transcript_path:
3235
- updates['transcript_path'] = transcript_path
3236
-
3237
- bg_env = os.environ.get('HCOM_BACKGROUND')
3238
- if bg_env:
3239
- updates['background'] = True
3240
- updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
3241
-
3242
- # Simple boolean: matched resume if existing_data found
3243
- is_matched_resume = (existing_data is not None)
3244
-
3245
- return instance_name, updates, is_matched_resume
3246
-
3247
- def pretooluse_decision(decision: str, reason: str) -> None:
3248
- """Exit PreToolUse hook with permission decision"""
3249
- output = {
3250
- "hookSpecificOutput": {
3251
- "hookEventName": "PreToolUse",
3252
- "permissionDecision": decision,
3253
- "permissionDecisionReason": reason
3254
- }
3255
- }
3256
- print(json.dumps(output, ensure_ascii=False))
3257
- sys.exit(0)
3258
-
3259
- def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3260
- """Handle PreToolUse hook - check force_closed, inject session_id"""
3261
- instance_data = load_instance_position(instance_name)
3262
- tool_name = hook_data.get('tool_name', '')
3263
- session_id = hook_data.get('session_id', '')
3264
-
3265
- # FORCE CLOSE CHECK - deny ALL tools
3266
- if instance_data.get('force_closed'):
3267
- 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.")
3268
-
3269
- # Record status for tool execution tracking (only if enabled)
3270
- if instance_data.get('enabled', False):
3271
- set_status(instance_name, 'tool_pending', tool_name)
3272
-
3273
- # Inject session_id into hcom commands via updatedInput
3274
- if tool_name == 'Bash' and session_id:
3275
- command = hook_data.get('tool_input', {}).get('command', '')
3276
-
3277
- # Match hcom commands for session_id injection and auto-approval
3278
- matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
3279
- if matches:
3280
- # Inject all if chained (&&, ||, ;, |), otherwise first only (avoids quoted text in messages)
3281
- inject_all = len(matches) > 1 and any(op in command[matches[0].end():matches[1].start()] for op in ['&&', '||', ';', '|'])
3282
- modified_command = HCOM_COMMAND_PATTERN.sub(rf'\g<0> --_hcom_session {session_id}', command, count=0 if inject_all else 1)
3283
-
3284
- output = {
3285
- "hookSpecificOutput": {
3286
- "hookEventName": "PreToolUse",
3287
- "permissionDecision": "allow",
3288
- "updatedInput": {
3289
- "command": modified_command
3290
- }
3291
- }
3292
- }
3293
- print(json.dumps(output, ensure_ascii=False))
3294
- sys.exit(0)
3295
-
3296
-
3297
-
3298
- def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3299
- """Handle Stop hook - poll for messages and deliver"""
3300
-
3301
- try:
3302
- updates['last_stop'] = time.time()
3303
- timeout = get_config().timeout
3304
- updates['wait_timeout'] = timeout
3305
- set_status(instance_name, 'waiting')
3306
-
3307
- try:
3308
- update_instance_position(instance_name, updates)
3309
- except Exception as e:
3310
- log_hook_error(f'stop:update_instance_position({instance_name})', e)
3311
-
3312
- start_time = time.time()
3313
-
3314
- try:
3315
- first_poll = True
3316
- last_heartbeat = start_time
3317
- # Actual polling loop - this IS the holding pattern
3318
- while time.time() - start_time < timeout:
3319
- if first_poll:
3320
- first_poll = False
3321
-
3322
- # Reload instance data each poll iteration
3323
- instance_data = load_instance_position(instance_name)
3324
-
3325
- # Check flag file FIRST (highest priority coordination signal)
3326
- flag_file = get_user_input_flag_file(instance_name)
3327
- if flag_file.exists():
3328
- try:
3329
- flag_file.unlink()
3330
- except (FileNotFoundError, PermissionError):
3331
- # Already deleted or locked, continue anyway
3332
- pass
3333
- sys.exit(0)
3334
-
3335
- # Check if session ended (SessionEnd hook fired) - exit without changing status
3336
- if instance_data.get('session_ended'):
3337
- sys.exit(0) # Don't overwrite session_ended status
3338
-
3339
- # Check if user input is pending (timestamp fallback) - exit cleanly if recent input
3340
- last_user_input = instance_data.get('last_user_input', 0)
3341
- if time.time() - last_user_input < 0.2:
3342
- sys.exit(0) # Don't overwrite status - let current status remain
3343
-
3344
- # Check if stopped/disabled - exit cleanly
3345
- if not instance_data.get('enabled', False):
3346
- sys.exit(0) # Preserve 'stopped' status set by cmd_stop
3347
-
3348
- # Check for new messages and deliver
3349
- if messages := get_unread_messages(instance_name, update_position=True):
3350
- messages_to_show = messages[:MAX_MESSAGES_PER_DELIVERY]
3351
- reason = format_hook_messages(messages_to_show, instance_name)
3352
- set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
3353
-
3354
- output = {"decision": "block", "reason": reason}
3355
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
3356
- sys.exit(2)
3357
-
3358
- # Update heartbeat every 0.5 seconds for staleness detection
3359
- now = time.time()
3360
- if now - last_heartbeat >= 0.5:
3361
- try:
3362
- update_instance_position(instance_name, {'last_stop': now})
3363
- last_heartbeat = now
3364
- except Exception as e:
3365
- log_hook_error(f'stop:heartbeat_update({instance_name})', e)
3366
-
3367
- time.sleep(STOP_HOOK_POLL_INTERVAL)
3368
-
3369
- except Exception as loop_e:
3370
- # Log polling loop errors but continue to cleanup
3371
- log_hook_error(f'stop:polling_loop({instance_name})', loop_e)
3372
-
3373
- # Timeout reached
3374
- set_status(instance_name, 'timeout')
3375
- sys.exit(0)
3376
-
3377
- except Exception as e:
3378
- # Log error and exit gracefully
3379
- log_hook_error('handle_stop', e)
3380
- sys.exit(0) # Preserve previous status on exception
3381
-
3382
- def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3383
- """Handle Notification hook - track permission requests"""
3384
- updates['notification_message'] = hook_data.get('message', '')
3385
- update_instance_position(instance_name, updates)
3386
- set_status(instance_name, 'blocked', hook_data.get('message', ''))
3387
-
3388
- def get_user_input_flag_file(instance_name: str) -> Path:
3389
- """Get path to user input coordination flag file"""
3390
- return hcom_path(FLAGS_DIR, f'{instance_name}.user_input')
3391
-
3392
- def wait_for_stop_exit(instance_name: str, max_wait: float = 0.2) -> int:
3393
- """
3394
- Wait for Stop hook to exit using flag file coordination.
3395
- Returns wait time in ms.
3396
- Strategy:
3397
- 1. Create flag file
3398
- 2. Wait for Stop hook to delete it (proof it exited)
3399
- 3. Fallback to timeout if Stop hook doesn't delete flag
3400
- """
3401
- start = time.time()
3402
- flag_file = get_user_input_flag_file(instance_name)
3403
-
3404
- # Wait for flag file to be deleted by Stop hook
3405
- while flag_file.exists() and time.time() - start < max_wait:
3406
- time.sleep(0.01)
3407
-
3408
- return int((time.time() - start) * 1000)
3409
-
3410
- def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], is_matched_resume: bool, instance_data: dict[str, Any] | None) -> None:
3411
- """Handle UserPromptSubmit hook - track when user sends messages"""
3412
- is_enabled = instance_data.get('enabled', False) if instance_data else False
3413
- last_stop = instance_data.get('last_stop', 0) if instance_data else 0
3414
- alias_announced = instance_data.get('alias_announced', False) if instance_data else False
3415
-
3416
- # Session_ended prevents user receiving messages(?) so reset it.
3417
- if is_matched_resume and instance_data and instance_data.get('session_ended'):
3418
- update_instance_position(instance_name, {'session_ended': False})
3419
- instance_data['session_ended'] = False # Resume path reactivates Stop hook polling
3420
-
3421
- # Coordinate with Stop hook only if enabled AND Stop hook is active
3422
- stop_is_active = (time.time() - last_stop) < 1.0
3423
-
3424
- if is_enabled and stop_is_active:
3425
- # Create flag file for coordination
3426
- flag_file = get_user_input_flag_file(instance_name)
3427
- try:
3428
- flag_file.touch()
3429
- except (OSError, PermissionError):
3430
- # Failed to create flag, fall back to timestamp-only coordination
3431
- pass
3432
-
3433
- # Set timestamp (backup mechanism)
3434
- updates['last_user_input'] = time.time()
3435
- update_instance_position(instance_name, updates)
3436
-
3437
- # Wait for Stop hook to delete flag file
3438
- wait_for_stop_exit(instance_name)
3439
-
3440
- # Build message based on what happened
3441
- msg = None
3442
-
3443
- # Determine if this is an HCOM-launched instance
3444
- is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
3445
-
3446
- # Show bootstrap if not already announced
3447
- if not alias_announced:
3448
- if is_hcom_launched:
3449
- # HCOM-launched instance - show bootstrap immediately
3450
- msg = build_hcom_bootstrap_text(instance_name)
3451
- update_instance_position(instance_name, {'alias_announced': True})
3452
- else:
3453
- # Vanilla Claude instance - check if user is about to run an hcom command
3454
- user_prompt = hook_data.get('prompt', '')
3455
- hcom_command_pattern = r'\bhcom\s+\w+'
3456
- if re.search(hcom_command_pattern, user_prompt, re.IGNORECASE):
3457
- # Bootstrap not shown yet - show it preemptively before hcom command runs
3458
- msg = "[HCOM COMMAND DETECTED]\n\n"
3459
- msg += build_hcom_bootstrap_text(instance_name)
3460
- update_instance_position(instance_name, {'alias_announced': True})
3461
-
3462
- # Add resume status note if we showed bootstrap for a matched resume
3463
- if msg and is_matched_resume:
3464
- if is_enabled:
3465
- msg += "\n[HCOM Session resumed. Your alias and conversation history preserved.]"
3466
- if msg:
3467
- output = {
3468
- "hookSpecificOutput": {
3469
- "hookEventName": "UserPromptSubmit",
3470
- "additionalContext": msg
3471
- }
3472
- }
3473
- print(json.dumps(output), file=sys.stdout)
3474
-
3475
- def handle_sessionstart(hook_data: dict[str, Any]) -> None:
3476
- """Handle SessionStart hook - initial msg & reads environment variables"""
3477
- # Only show message for HCOM-launched instances
3478
- if os.environ.get('HCOM_LAUNCHED') == '1':
3479
- parts = f"[HCOM is started, you can send messages with the command: {build_hcom_command()} send]"
3480
- else:
3481
- parts = f"[You can start HCOM with the command: {build_hcom_command()} start]"
3482
-
3483
- output = {
3484
- "hookSpecificOutput": {
3485
- "hookEventName": "SessionStart",
3486
- "additionalContext": parts
3487
- }
3488
- }
3489
-
3490
- print(json.dumps(output))
3491
-
3492
- def handle_posttooluse(hook_data: dict[str, Any], instance_name: str) -> None:
3493
- """Handle PostToolUse hook - show launch context or bootstrap"""
3494
- command = hook_data.get('tool_input', {}).get('command', '')
3495
- instance_data = load_instance_position(instance_name)
3496
-
3497
- # Check for help or launch commands (combined pattern)
3498
- if re.search(r'\bhcom\s+(?:(?:help|--help|-h)\b|\d+)', command):
3499
- if not instance_data.get('launch_context_announced', False):
3500
- msg = build_launch_context(instance_name)
3501
- update_instance_position(instance_name, {'launch_context_announced': True})
3502
-
3503
- output = {
3504
- "hookSpecificOutput": {
3505
- "hookEventName": "PostToolUse",
3506
- "additionalContext": msg
3507
- }
3508
- }
3509
- print(json.dumps(output, ensure_ascii=False))
3510
- return
3511
-
3512
- # Check HCOM_COMMAND_PATTERN for bootstrap (other hcom commands)
3513
- matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
3514
-
3515
- if not matches:
3516
- return
3517
-
3518
- # Show bootstrap if not announced yet
3519
- if not instance_data.get('alias_announced', False):
3520
- msg = build_hcom_bootstrap_text(instance_name)
3521
- update_instance_position(instance_name, {'alias_announced': True})
3522
-
3523
- output = {
3524
- "hookSpecificOutput": {
3525
- "hookEventName": "PostToolUse",
3526
- "additionalContext": msg
3527
- }
3528
- }
3529
- print(json.dumps(output, ensure_ascii=False))
3530
-
3531
- def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
3532
- """Handle SessionEnd hook - mark session as ended and set final status"""
3533
- reason = hook_data.get('reason', 'unknown')
3534
-
3535
- # Set session_ended flag to tell Stop hook to exit
3536
- updates['session_ended'] = True
3537
-
3538
- # Set status with reason as context (reason: clear, logout, prompt_input_exit, other)
3539
- set_status(instance_name, 'session_ended', reason)
3540
-
3541
- try:
3542
- update_instance_position(instance_name, updates)
3543
- except Exception as e:
3544
- log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
3545
-
3546
- def should_skip_vanilla_instance(hook_type: str, hook_data: dict) -> bool:
3547
- """
3548
- Returns True if hook should exit early.
3549
- Vanilla instances (not HCOM-launched) exit early unless:
3550
- - Enabled
3551
- - PreToolUse (handles opt-in)
3552
- - UserPromptSubmit with hcom command in prompt (shows preemptive bootstrap)
3553
- """
3554
- # PreToolUse always runs (handles toggle commands)
3555
- # HCOM-launched instances always run
3556
- if hook_type == 'pre' or os.environ.get('HCOM_LAUNCHED') == '1':
3557
- return False
3558
-
3559
- session_id = hook_data.get('session_id', '')
3560
- if not session_id: # No session_id = can't identify instance, skip hook
3561
- return True
3562
-
3563
- instance_name = get_display_name(session_id, get_config().tag)
3564
- instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
3565
-
3566
- if not instance_file.exists():
3567
- # Allow UserPromptSubmit if prompt contains hcom command
3568
- if hook_type == 'userpromptsubmit':
3569
- user_prompt = hook_data.get('prompt', '')
3570
- return not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE)
3571
- return True
3572
-
3573
- return False
3574
-
3575
- def handle_hook(hook_type: str) -> None:
3576
- """Unified hook handler for all HCOM hooks"""
3577
- hook_data = json.load(sys.stdin)
3578
-
3579
- if not ensure_hcom_directories():
3580
- log_hook_error('handle_hook', Exception('Failed to create directories'))
3581
- sys.exit(0)
3582
-
3583
- # SessionStart is standalone - no instance files
3584
- if hook_type == 'sessionstart':
3585
- handle_sessionstart(hook_data)
3586
- sys.exit(0)
3587
-
3588
- # Vanilla instance check - exit early if should skip
3589
- if should_skip_vanilla_instance(hook_type, hook_data):
3590
- sys.exit(0)
3591
-
3592
- # Initialize instance context (creates file if needed, reuses existing if session_id matches)
3593
- instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
3594
-
3595
- # Load instance data once (for enabled check and to pass to handlers)
3596
- instance_data = None
3597
- if hook_type != 'pre':
3598
- instance_data = load_instance_position(instance_name)
3599
-
3600
- # Skip enabled check for UserPromptSubmit when bootstrap needs to be shown
3601
- # (alias_announced=false means bootstrap hasn't been shown yet)
3602
- skip_enabled_check = (hook_type == 'userpromptsubmit' and
3603
- not instance_data.get('alias_announced', False))
3604
-
3605
- if not skip_enabled_check and not instance_data.get('enabled', False):
3606
- sys.exit(0)
3607
-
3608
- match hook_type:
3609
- case 'pre':
3610
- handle_pretooluse(hook_data, instance_name)
3611
- case 'post':
3612
- handle_posttooluse(hook_data, instance_name)
3613
- case 'poll':
3614
- handle_stop(hook_data, instance_name, updates, instance_data)
3615
- case 'notify':
3616
- handle_notify(hook_data, instance_name, updates, instance_data)
3617
- case 'userpromptsubmit':
3618
- handle_userpromptsubmit(hook_data, instance_name, updates, is_matched_resume, instance_data)
3619
- case 'sessionend':
3620
- handle_sessionend(hook_data, instance_name, updates, instance_data)
3621
-
3622
- sys.exit(0)
3623
-
3624
-
3625
- # ==================== Main Entry Point ====================
3626
-
3627
- def main(argv: list[str] | None = None) -> int | None:
3628
- """Main command dispatcher"""
3629
- if argv is None:
3630
- argv = sys.argv[1:]
3631
- else:
3632
- argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
3633
-
3634
- # Hook handlers only (called BY hooks, not users)
3635
- if argv and argv[0] in ('poll', 'notify', 'pre', 'post', 'sessionstart', 'userpromptsubmit', 'sessionend'):
3636
- handle_hook(argv[0])
3637
- return 0
3638
-
3639
- # Ensure directories exist first (required for version check cache)
3640
- if not ensure_hcom_directories():
3641
- print(format_error("Failed to create HCOM directories"), file=sys.stderr)
3642
- return 1
3643
-
3644
- # Check for updates and show message if available (once daily check, persists until upgrade)
3645
- if msg := get_update_notice():
3646
- print(msg, file=sys.stderr)
3647
-
3648
- # Ensure hooks current (warns but never blocks)
3649
- ensure_hooks_current()
3650
-
3651
- # Route to commands
3652
- try:
3653
- if not argv or argv[0] in ('help', '--help', '-h'):
3654
- return cmd_help()
3655
- elif argv[0] == 'send_cli':
3656
- if len(argv) < 2:
3657
- print(format_error("Message required"), file=sys.stderr)
3658
- return 1
3659
- return send_cli(argv[1])
3660
- elif argv[0] == 'watch':
3661
- return cmd_watch(argv[1:])
3662
- elif argv[0] == 'send':
3663
- return cmd_send(argv[1:])
3664
- elif argv[0] == 'stop':
3665
- return cmd_stop(argv[1:])
3666
- elif argv[0] == 'start':
3667
- return cmd_start(argv[1:])
3668
- elif argv[0] == 'reset':
3669
- return cmd_reset(argv[1:])
3670
- elif argv[0].isdigit() or argv[0] == 'claude':
3671
- # Launch instances: hcom <1-100> [args] or hcom claude [args]
3672
- return cmd_launch(argv)
3673
- else:
3674
- print(format_error(
3675
- f"Unknown command: {argv[0]}",
3676
- "Run 'hcom --help' for usage"
3677
- ), file=sys.stderr)
3678
- return 1
3679
- except CLIError as exc:
3680
- print(str(exc), file=sys.stderr)
3681
- return 1
3682
-
3683
- if __name__ == '__main__':
3684
- sys.exit(main())
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())