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