hcom 0.4.0__py3-none-any.whl → 0.4.2.post3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hcom might be problematic. Click here for more details.
- hcom/__init__.py +1 -1
- hcom/__main__.py +1246 -1149
- {hcom-0.4.0.dist-info → hcom-0.4.2.post3.dist-info}/METADATA +125 -142
- hcom-0.4.2.post3.dist-info/RECORD +7 -0
- hcom-0.4.0.dist-info/RECORD +0 -7
- {hcom-0.4.0.dist-info → hcom-0.4.2.post3.dist-info}/WHEEL +0 -0
- {hcom-0.4.0.dist-info → hcom-0.4.2.post3.dist-info}/entry_points.txt +0 -0
- {hcom-0.4.0.dist-info → hcom-0.4.2.post3.dist-info}/top_level.txt +0 -0
hcom/__main__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
hcom
|
|
3
|
+
hcom
|
|
4
4
|
CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -17,54 +17,22 @@ import time
|
|
|
17
17
|
import select
|
|
18
18
|
import platform
|
|
19
19
|
import random
|
|
20
|
-
import argparse
|
|
21
20
|
from pathlib import Path
|
|
22
21
|
from datetime import datetime, timedelta
|
|
23
|
-
from typing import Any, NamedTuple
|
|
24
|
-
from dataclasses import dataclass
|
|
25
|
-
from enum import Enum, auto
|
|
22
|
+
from typing import Any, NamedTuple
|
|
23
|
+
from dataclasses import dataclass
|
|
26
24
|
|
|
27
25
|
if sys.version_info < (3, 10):
|
|
28
26
|
sys.exit("Error: hcom requires Python 3.10 or higher")
|
|
29
27
|
|
|
30
|
-
__version__ = "0.
|
|
31
|
-
|
|
32
|
-
# ==================== Session Scenario Types ====================
|
|
33
|
-
|
|
34
|
-
class SessionScenario(Enum):
|
|
35
|
-
"""Explicit session startup scenarios for clear logic flow"""
|
|
36
|
-
FRESH_START = auto() # New session, new instance
|
|
37
|
-
MATCHED_RESUME = auto() # Resume with matching session_id (reuse instance)
|
|
38
|
-
UNMATCHED_RESUME = auto() # Resume with no match (new instance, needs recovery)
|
|
39
|
-
|
|
40
|
-
@dataclass
|
|
41
|
-
class HookContext:
|
|
42
|
-
"""Consolidated context for hook handling with all decisions made"""
|
|
43
|
-
instance_name: str
|
|
44
|
-
updates: dict
|
|
45
|
-
scenario: SessionScenario | None # None = deferred decision (SessionStart wrong session_id)
|
|
46
|
-
|
|
47
|
-
@property
|
|
48
|
-
def bypass_enabled_check(self) -> bool:
|
|
49
|
-
"""Unmatched resume needs critical message even if disabled"""
|
|
50
|
-
return self.scenario == SessionScenario.UNMATCHED_RESUME
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def needs_critical_prompt(self) -> bool:
|
|
54
|
-
"""Should show critical recovery message?"""
|
|
55
|
-
return self.scenario == SessionScenario.UNMATCHED_RESUME
|
|
56
|
-
|
|
57
|
-
@property
|
|
58
|
-
def is_resume(self) -> bool:
|
|
59
|
-
"""Is this any kind of resume?"""
|
|
60
|
-
return self.scenario in (SessionScenario.MATCHED_RESUME, SessionScenario.UNMATCHED_RESUME)
|
|
28
|
+
__version__ = "0.5.0"
|
|
61
29
|
|
|
62
30
|
# ==================== Constants ====================
|
|
63
31
|
|
|
64
32
|
IS_WINDOWS = sys.platform == 'win32'
|
|
65
33
|
|
|
66
|
-
def is_wsl():
|
|
67
|
-
"""Detect if running in WSL
|
|
34
|
+
def is_wsl() -> bool:
|
|
35
|
+
"""Detect if running in WSL"""
|
|
68
36
|
if platform.system() != 'Linux':
|
|
69
37
|
return False
|
|
70
38
|
try:
|
|
@@ -73,7 +41,7 @@ def is_wsl():
|
|
|
73
41
|
except (FileNotFoundError, PermissionError, OSError):
|
|
74
42
|
return False
|
|
75
43
|
|
|
76
|
-
def is_termux():
|
|
44
|
+
def is_termux() -> bool:
|
|
77
45
|
"""Detect if running in Termux on Android"""
|
|
78
46
|
return (
|
|
79
47
|
'TERMUX_VERSION' in os.environ or # Primary: Works all versions
|
|
@@ -91,7 +59,6 @@ CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
|
|
|
91
59
|
# Timing constants
|
|
92
60
|
FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
|
|
93
61
|
STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
|
|
94
|
-
MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
|
|
95
62
|
|
|
96
63
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
97
64
|
AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
|
|
@@ -148,95 +115,66 @@ if IS_WINDOWS or is_wsl():
|
|
|
148
115
|
# ==================== Error Handling Strategy ====================
|
|
149
116
|
# Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
|
|
150
117
|
# CLI: Can raise exceptions for user feedback. Check return values.
|
|
151
|
-
# Critical I/O: atomic_write, save_instance_position
|
|
118
|
+
# Critical I/O: atomic_write, save_instance_position
|
|
152
119
|
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
153
120
|
|
|
154
|
-
# ==================== CLI
|
|
121
|
+
# ==================== CLI Errors ====================
|
|
155
122
|
|
|
156
123
|
class CLIError(Exception):
|
|
157
124
|
"""Raised when arguments cannot be mapped to command semantics."""
|
|
158
125
|
|
|
159
|
-
|
|
160
|
-
class OpenCommand:
|
|
161
|
-
count: int
|
|
162
|
-
agents: list[str]
|
|
163
|
-
prefix: str | None
|
|
164
|
-
background: bool
|
|
165
|
-
claude_args: list[str]
|
|
126
|
+
# ==================== Help Text ====================
|
|
166
127
|
|
|
167
|
-
|
|
168
|
-
class WatchCommand:
|
|
169
|
-
mode: str # 'interactive', 'logs', 'status', 'wait'
|
|
170
|
-
wait_seconds: int | None
|
|
128
|
+
HELP_TEXT = """hcom - Claude Hook Comms
|
|
171
129
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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]
|
|
178
136
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
183
141
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
189
147
|
|
|
190
|
-
|
|
148
|
+
send "msg" Send message to all instances
|
|
149
|
+
send "@alias msg" Send to specific instance/group
|
|
191
150
|
|
|
192
|
-
|
|
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)
|
|
193
155
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
hcom
|
|
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"""
|
|
200
173
|
|
|
201
|
-
Commands:
|
|
202
|
-
open Launch Claude instances (default count: 1)
|
|
203
|
-
watch Monitor conversation dashboard
|
|
204
|
-
stop Stop instances, clear conversation, or remove hooks
|
|
205
|
-
start Start stopped instances
|
|
206
|
-
send Send message to instances
|
|
207
|
-
|
|
208
|
-
Open options:
|
|
209
|
-
[count] Number of instances per agent (default 1)
|
|
210
|
-
-a, --agent AGENT Agent to launch (repeatable)
|
|
211
|
-
-t, --prefix PREFIX Team prefix for names
|
|
212
|
-
-p, --background Launch in background
|
|
213
|
-
|
|
214
|
-
Stop targets:
|
|
215
|
-
(no arg) Stop HCOM for current instance (when inside)
|
|
216
|
-
<alias> Stop HCOM for specific instance
|
|
217
|
-
all Stop all instances + clear & archive conversation
|
|
218
|
-
hooking Remove hooks from current directory
|
|
219
|
-
hooking --all Remove hooks from all tracked directories
|
|
220
|
-
everything Stop all + clear conversation + remove all hooks
|
|
221
|
-
|
|
222
|
-
Start targets:
|
|
223
|
-
(no arg) Start HCOM for current instance (when inside)
|
|
224
|
-
<alias> Start HCOM for specific instance
|
|
225
|
-
hooking Install hooks in current directory
|
|
226
|
-
|
|
227
|
-
Watch options:
|
|
228
|
-
--logs Show message history
|
|
229
|
-
--status Show instance status JSON
|
|
230
|
-
--wait [SEC] Wait for new messages (default 60s)
|
|
231
|
-
|
|
232
|
-
Stop flags:
|
|
233
|
-
--force Force stop (deny Bash tool use)
|
|
234
|
-
|
|
235
|
-
Docs: https://github.com/aannoo/claude-hook-comms#readme"""
|
|
236
174
|
|
|
237
175
|
# ==================== Logging ====================
|
|
238
176
|
|
|
239
|
-
def log_hook_error(hook_name: str, error: Exception | None = None):
|
|
177
|
+
def log_hook_error(hook_name: str, error: Exception | str | None = None) -> None:
|
|
240
178
|
"""Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
|
|
241
179
|
import traceback
|
|
242
180
|
try:
|
|
@@ -253,41 +191,22 @@ def log_hook_error(hook_name: str, error: Exception | None = None):
|
|
|
253
191
|
pass # Silent failure in error logging
|
|
254
192
|
|
|
255
193
|
# ==================== Config Defaults ====================
|
|
194
|
+
# Config precedence: env var > ~/.hcom/config.env > defaults
|
|
195
|
+
# All config via HcomConfig dataclass (timeout, terminal, prompt, hints, tag, agent)
|
|
256
196
|
|
|
257
|
-
#
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
sender_name: str = "bigboss"
|
|
264
|
-
sender_emoji: str = "🐳"
|
|
265
|
-
cli_hints: str = ""
|
|
266
|
-
wait_timeout: int = 1800 # 30mins
|
|
267
|
-
max_message_size: int = 1048576 # 1MB
|
|
268
|
-
max_messages_per_delivery: int = 50
|
|
269
|
-
first_use_text: str = "Essential, concise messages only, say hi in hcom chat now"
|
|
270
|
-
instance_hints: str = ""
|
|
271
|
-
env_overrides: dict = field(default_factory=dict)
|
|
272
|
-
auto_watch: bool = True # Auto-launch watch dashboard after open
|
|
273
|
-
|
|
274
|
-
DEFAULT_CONFIG = HcomConfig()
|
|
275
|
-
|
|
276
|
-
_config = None
|
|
277
|
-
|
|
278
|
-
# Generate env var mappings from dataclass fields (except env_overrides)
|
|
279
|
-
HOOK_SETTINGS = {
|
|
280
|
-
field: f"HCOM_{field.upper()}"
|
|
281
|
-
for field in DEFAULT_CONFIG.__dataclass_fields__
|
|
282
|
-
if field != 'env_overrides'
|
|
283
|
-
}
|
|
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)
|
|
284
203
|
|
|
285
204
|
# Path constants
|
|
286
205
|
LOG_FILE = "hcom.log"
|
|
287
206
|
INSTANCES_DIR = "instances"
|
|
288
207
|
LOGS_DIR = "logs"
|
|
289
208
|
SCRIPTS_DIR = "scripts"
|
|
290
|
-
CONFIG_FILE = "config.
|
|
209
|
+
CONFIG_FILE = "config.env"
|
|
291
210
|
ARCHIVE_DIR = "archive"
|
|
292
211
|
|
|
293
212
|
# Hook type constants
|
|
@@ -296,6 +215,31 @@ LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES + ['PostToolUse'] # For backward compatib
|
|
|
296
215
|
HOOK_COMMANDS = ['sessionstart', 'userpromptsubmit', 'pre', 'poll', 'notify', 'sessionend']
|
|
297
216
|
LEGACY_HOOK_COMMANDS = HOOK_COMMANDS + ['post']
|
|
298
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
|
+
|
|
299
243
|
# ==================== File System Utilities ====================
|
|
300
244
|
|
|
301
245
|
def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
|
|
@@ -374,10 +318,6 @@ def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, m
|
|
|
374
318
|
|
|
375
319
|
return default
|
|
376
320
|
|
|
377
|
-
# ==================== Outbox System (REMOVED) ====================
|
|
378
|
-
# Identity via session_id injection in handle_pretooluse (line 3134)
|
|
379
|
-
# PreToolUse hook injects --_hcom_session, commands use get_display_name() for resolution
|
|
380
|
-
|
|
381
321
|
def get_instance_file(instance_name: str) -> Path:
|
|
382
322
|
"""Get path to instance's position file with path traversal protection"""
|
|
383
323
|
# Sanitize instance name to prevent directory traversal
|
|
@@ -409,6 +349,18 @@ def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
|
|
|
409
349
|
except (OSError, PermissionError, ValueError):
|
|
410
350
|
return False
|
|
411
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
|
+
|
|
412
364
|
def load_all_positions() -> dict[str, dict[str, Any]]:
|
|
413
365
|
"""Load positions from all instance files"""
|
|
414
366
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
@@ -436,93 +388,294 @@ def clear_all_positions() -> None:
|
|
|
436
388
|
|
|
437
389
|
# ==================== Configuration System ====================
|
|
438
390
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
445
473
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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 = {}
|
|
449
506
|
|
|
450
|
-
#
|
|
451
|
-
|
|
507
|
+
# Dangerous shell metacharacters that enable command injection
|
|
508
|
+
DANGEROUS_CHARS = ['`', '$', ';', '|', '&', '\n', '\r']
|
|
452
509
|
|
|
453
510
|
try:
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
491
586
|
|
|
492
|
-
|
|
493
|
-
|
|
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())
|
|
494
601
|
|
|
495
|
-
|
|
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]]:
|
|
496
610
|
"""Get hook command - hooks always run, Python code gates participation
|
|
497
611
|
|
|
498
612
|
Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
|
|
499
613
|
Participation is controlled by enabled flag in instance JSON files.
|
|
500
614
|
"""
|
|
501
|
-
python_path = sys.executable
|
|
502
|
-
script_path = str(Path(__file__).resolve())
|
|
503
|
-
|
|
504
615
|
if IS_WINDOWS:
|
|
505
616
|
# Windows: use python path directly
|
|
506
|
-
|
|
507
|
-
return f'"{python_path}" "{script_path}"', {}
|
|
508
|
-
return f'{python_path} {script_path}', {}
|
|
617
|
+
return _build_quoted_invocation(), {}
|
|
509
618
|
else:
|
|
510
|
-
# Unix: Use HCOM env var from settings.
|
|
619
|
+
# Unix: Use HCOM env var from settings.json
|
|
511
620
|
return '${HCOM}', {}
|
|
512
621
|
|
|
513
622
|
def _detect_hcom_command_type() -> str:
|
|
514
|
-
"""Detect how to invoke hcom
|
|
515
|
-
|
|
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():
|
|
516
634
|
return 'short'
|
|
517
635
|
elif 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
|
|
518
636
|
return 'uvx'
|
|
637
|
+
elif shutil.which('hcom'):
|
|
638
|
+
return 'short'
|
|
519
639
|
else:
|
|
520
640
|
return 'full'
|
|
521
641
|
|
|
522
|
-
def
|
|
523
|
-
"""
|
|
524
|
-
|
|
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
|
|
525
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"""
|
|
526
679
|
# Determine command type (cached or detect)
|
|
527
680
|
cmd_type = None
|
|
528
681
|
if instance_name:
|
|
@@ -540,32 +693,40 @@ def build_send_command(example_msg: str = '', instance_name: str | None = None)
|
|
|
540
693
|
|
|
541
694
|
# Build command based on type
|
|
542
695
|
if cmd_type == 'short':
|
|
543
|
-
return
|
|
696
|
+
return 'hcom'
|
|
544
697
|
elif cmd_type == 'uvx':
|
|
545
|
-
return
|
|
698
|
+
return 'uvx hcom'
|
|
546
699
|
else:
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
return f'{python_path} {script_path} send{msg}'
|
|
700
|
+
# Full path fallback
|
|
701
|
+
return _build_quoted_invocation()
|
|
550
702
|
|
|
551
|
-
def
|
|
552
|
-
"""Build
|
|
553
|
-
|
|
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}'
|
|
554
708
|
|
|
555
|
-
|
|
556
|
-
|
|
709
|
+
def build_claude_env() -> dict[str, str]:
|
|
710
|
+
"""Build environment variables for Claude instances
|
|
557
711
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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 = {}
|
|
562
716
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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)
|
|
566
724
|
|
|
567
|
-
#
|
|
568
|
-
|
|
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
|
|
569
730
|
|
|
570
731
|
return env
|
|
571
732
|
|
|
@@ -580,9 +741,8 @@ def validate_message(message: str) -> str | None:
|
|
|
580
741
|
if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
|
|
581
742
|
return format_error("Message contains control characters")
|
|
582
743
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
return format_error(f"Message too large (max {max_size} chars)")
|
|
744
|
+
if len(message) > MAX_MESSAGE_SIZE:
|
|
745
|
+
return format_error(f"Message too large (max {MAX_MESSAGE_SIZE} chars)")
|
|
586
746
|
|
|
587
747
|
return None
|
|
588
748
|
|
|
@@ -607,32 +767,62 @@ def send_message(from_instance: str, message: str) -> bool:
|
|
|
607
767
|
|
|
608
768
|
def build_hcom_bootstrap_text(instance_name: str) -> str:
|
|
609
769
|
"""Build comprehensive HCOM bootstrap context for instances"""
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
-
|
|
628
|
-
-
|
|
629
|
-
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
+
|
|
636
826
|
|
|
637
827
|
def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
|
|
638
828
|
"""Check if message should be delivered based on @-mentions"""
|
|
@@ -653,8 +843,7 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
|
|
|
653
843
|
return True
|
|
654
844
|
|
|
655
845
|
# Check if any mention is for the CLI sender (bigboss)
|
|
656
|
-
|
|
657
|
-
sender_mentioned = any(sender_name.lower().startswith(mention.lower()) for mention in mentions)
|
|
846
|
+
sender_mentioned = any(SENDER.lower().startswith(mention.lower()) for mention in mentions)
|
|
658
847
|
|
|
659
848
|
# If we have all_instance_names, check if ANY mention matches ANY instance or sender
|
|
660
849
|
if all_instance_names:
|
|
@@ -775,7 +964,7 @@ def strip_frontmatter(content: str) -> str:
|
|
|
775
964
|
return '\n'.join(lines[i+1:]).strip()
|
|
776
965
|
return content
|
|
777
966
|
|
|
778
|
-
def get_display_name(session_id: str | None,
|
|
967
|
+
def get_display_name(session_id: str | None, tag: str | None = None) -> str:
|
|
779
968
|
"""Get display name for instance using session_id"""
|
|
780
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']
|
|
781
970
|
# Phonetic letters (5 per syllable, matches syls order)
|
|
@@ -819,11 +1008,18 @@ def get_display_name(session_id: str | None, prefix: str | None = None) -> str:
|
|
|
819
1008
|
# session_id is required - fail gracefully
|
|
820
1009
|
raise ValueError("session_id required for instance naming")
|
|
821
1010
|
|
|
822
|
-
if
|
|
823
|
-
|
|
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}"
|
|
824
1020
|
return base_name
|
|
825
1021
|
|
|
826
|
-
def resolve_instance_name(session_id: str,
|
|
1022
|
+
def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str, dict | None]:
|
|
827
1023
|
"""
|
|
828
1024
|
Resolve instance name for a session_id.
|
|
829
1025
|
Searches existing instances first (reuses if found), generates new name if not found.
|
|
@@ -843,36 +1039,18 @@ def resolve_instance_name(session_id: str, prefix: str | None = None) -> tuple[s
|
|
|
843
1039
|
continue
|
|
844
1040
|
|
|
845
1041
|
# Not found - generate new name
|
|
846
|
-
instance_name = get_display_name(session_id,
|
|
1042
|
+
instance_name = get_display_name(session_id, tag)
|
|
847
1043
|
return instance_name, None
|
|
848
1044
|
|
|
849
|
-
def _remove_hcom_hooks_from_settings(settings):
|
|
1045
|
+
def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
|
|
850
1046
|
"""Remove hcom hooks from settings dict"""
|
|
851
1047
|
if not isinstance(settings, dict) or 'hooks' not in settings:
|
|
852
1048
|
return
|
|
853
|
-
|
|
1049
|
+
|
|
854
1050
|
if not isinstance(settings['hooks'], dict):
|
|
855
1051
|
return
|
|
856
|
-
|
|
857
|
-
import copy
|
|
858
1052
|
|
|
859
|
-
|
|
860
|
-
# Current hooks (pattern 1): ${HCOM:-...} environment variable
|
|
861
|
-
# Legacy hooks (patterns 2-7): Older formats that need cleanup
|
|
862
|
-
# - HCOM_ACTIVE conditionals (removed for toggle implementation)
|
|
863
|
-
# - Direct command invocation with specific hook args
|
|
864
|
-
hook_args_pattern = '|'.join(LEGACY_HOOK_COMMANDS)
|
|
865
|
-
hcom_patterns = [
|
|
866
|
-
r'\$\{?HCOM', # Current: Environment variable ${HCOM:-...}
|
|
867
|
-
r'\bHCOM_ACTIVE.*hcom\.py', # LEGACY: Unix HCOM_ACTIVE conditional
|
|
868
|
-
r'IF\s+"%HCOM_ACTIVE%"', # LEGACY: Windows HCOM_ACTIVE conditional
|
|
869
|
-
rf'\bhcom\s+({hook_args_pattern})\b', # LEGACY: Direct hcom command
|
|
870
|
-
rf'\buvx\s+hcom\s+({hook_args_pattern})\b', # LEGACY: uvx hcom command
|
|
871
|
-
rf'hcom\.py["\']?\s+({hook_args_pattern})\b', # LEGACY: hcom.py with optional quote
|
|
872
|
-
rf'["\'][^"\']*hcom\.py["\']?\s+({hook_args_pattern})\b(?=\s|$)', # LEGACY: Quoted path
|
|
873
|
-
r'sh\s+-c.*hcom', # LEGACY: Shell wrapper
|
|
874
|
-
]
|
|
875
|
-
compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
|
|
1053
|
+
import copy
|
|
876
1054
|
|
|
877
1055
|
# Check all hook types including PostToolUse for backward compatibility cleanup
|
|
878
1056
|
for event in LEGACY_HOOK_TYPES:
|
|
@@ -894,7 +1072,7 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
894
1072
|
hook for hook in matcher_copy.get('hooks', [])
|
|
895
1073
|
if not any(
|
|
896
1074
|
pattern.search(hook.get('command', ''))
|
|
897
|
-
for pattern in
|
|
1075
|
+
for pattern in HCOM_HOOK_PATTERNS
|
|
898
1076
|
)
|
|
899
1077
|
]
|
|
900
1078
|
|
|
@@ -919,7 +1097,7 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
919
1097
|
del settings['env']
|
|
920
1098
|
|
|
921
1099
|
|
|
922
|
-
def build_env_string(env_vars, format_type="bash"):
|
|
1100
|
+
def build_env_string(env_vars: dict[str, Any], format_type: str = "bash") -> str:
|
|
923
1101
|
"""Build environment variable string for bash shells"""
|
|
924
1102
|
if format_type == "bash_export":
|
|
925
1103
|
# Properly escape values for bash
|
|
@@ -936,12 +1114,12 @@ def format_error(message: str, suggestion: str | None = None) -> str:
|
|
|
936
1114
|
return base
|
|
937
1115
|
|
|
938
1116
|
|
|
939
|
-
def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
1117
|
+
def has_claude_arg(claude_args: list[str] | None, arg_names: list[str], arg_prefixes: tuple[str, ...]) -> bool:
|
|
940
1118
|
"""Check if argument already exists in claude_args"""
|
|
941
|
-
return claude_args and any(
|
|
1119
|
+
return bool(claude_args and any(
|
|
942
1120
|
arg in arg_names or arg.startswith(arg_prefixes)
|
|
943
1121
|
for arg in claude_args
|
|
944
|
-
)
|
|
1122
|
+
))
|
|
945
1123
|
|
|
946
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]:
|
|
947
1125
|
"""Build Claude command with proper argument handling
|
|
@@ -982,21 +1160,19 @@ def build_claude_command(agent_content: str | None = None, claude_args: list[str
|
|
|
982
1160
|
|
|
983
1161
|
cmd_parts.append(flag)
|
|
984
1162
|
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
cmd_parts.append(shlex.quote(initial_prompt))
|
|
991
|
-
|
|
1163
|
+
|
|
1164
|
+
# Add initial prompt if non-empty
|
|
1165
|
+
if initial_prompt:
|
|
1166
|
+
cmd_parts.append(shlex.quote(initial_prompt))
|
|
1167
|
+
|
|
992
1168
|
return ' '.join(cmd_parts), temp_file_path
|
|
993
1169
|
|
|
994
|
-
def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
1170
|
+
def create_bash_script(script_file: str, env: dict[str, Any], cwd: str | None, command_str: str, background: bool = False) -> None:
|
|
995
1171
|
"""Create a bash script for terminal launch
|
|
996
1172
|
Scripts provide uniform execution across all platforms/terminals.
|
|
997
1173
|
Cleanup behavior:
|
|
998
1174
|
- Normal scripts: append 'rm -f' command for self-deletion
|
|
999
|
-
- Background scripts: persist until
|
|
1175
|
+
- Background scripts: persist until `hcom reset logs` cleanup (24 hours)
|
|
1000
1176
|
- Agent scripts: treated like background (contain 'hcom_agent_')
|
|
1001
1177
|
"""
|
|
1002
1178
|
try:
|
|
@@ -1064,7 +1240,7 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
|
1064
1240
|
if platform.system() != 'Windows':
|
|
1065
1241
|
os.chmod(script_file, 0o755)
|
|
1066
1242
|
|
|
1067
|
-
def find_bash_on_windows():
|
|
1243
|
+
def find_bash_on_windows() -> str | None:
|
|
1068
1244
|
"""Find Git Bash on Windows, avoiding WSL's bash launcher"""
|
|
1069
1245
|
# Build prioritized list of bash candidates
|
|
1070
1246
|
candidates = []
|
|
@@ -1106,11 +1282,11 @@ def find_bash_on_windows():
|
|
|
1106
1282
|
return None
|
|
1107
1283
|
|
|
1108
1284
|
# New helper functions for platform-specific terminal launching
|
|
1109
|
-
def get_macos_terminal_argv():
|
|
1285
|
+
def get_macos_terminal_argv() -> list[str]:
|
|
1110
1286
|
"""Return macOS Terminal.app launch command as argv list."""
|
|
1111
1287
|
return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
|
|
1112
1288
|
|
|
1113
|
-
def get_windows_terminal_argv():
|
|
1289
|
+
def get_windows_terminal_argv() -> list[str]:
|
|
1114
1290
|
"""Return Windows terminal launcher as argv list."""
|
|
1115
1291
|
if not (bash_exe := find_bash_on_windows()):
|
|
1116
1292
|
raise Exception(format_error("Git Bash not found"))
|
|
@@ -1119,7 +1295,7 @@ def get_windows_terminal_argv():
|
|
|
1119
1295
|
return ['wt', bash_exe, '{script}']
|
|
1120
1296
|
return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
|
|
1121
1297
|
|
|
1122
|
-
def get_linux_terminal_argv():
|
|
1298
|
+
def get_linux_terminal_argv() -> list[str] | None:
|
|
1123
1299
|
"""Return first available Linux terminal as argv list."""
|
|
1124
1300
|
terminals = [
|
|
1125
1301
|
('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
|
|
@@ -1138,7 +1314,7 @@ def get_linux_terminal_argv():
|
|
|
1138
1314
|
|
|
1139
1315
|
return None
|
|
1140
1316
|
|
|
1141
|
-
def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
|
|
1317
|
+
def windows_hidden_popen(argv: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None, stdout: Any = None) -> subprocess.Popen:
|
|
1142
1318
|
"""Create hidden Windows process without console window."""
|
|
1143
1319
|
if IS_WINDOWS:
|
|
1144
1320
|
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
|
@@ -1165,7 +1341,7 @@ PLATFORM_TERMINAL_GETTERS = {
|
|
|
1165
1341
|
'Linux': get_linux_terminal_argv,
|
|
1166
1342
|
}
|
|
1167
1343
|
|
|
1168
|
-
def _parse_terminal_command(template, script_file):
|
|
1344
|
+
def _parse_terminal_command(template: str, script_file: str) -> list[str]:
|
|
1169
1345
|
"""Parse terminal command template safely to prevent shell injection.
|
|
1170
1346
|
Parses the template FIRST, then replaces {script} placeholder in the
|
|
1171
1347
|
parsed tokens. This avoids shell injection and handles paths with spaces.
|
|
@@ -1203,7 +1379,7 @@ def _parse_terminal_command(template, script_file):
|
|
|
1203
1379
|
|
|
1204
1380
|
return replaced
|
|
1205
1381
|
|
|
1206
|
-
def launch_terminal(command, env, cwd=None, background=False):
|
|
1382
|
+
def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, background: bool = False) -> str | bool | None:
|
|
1207
1383
|
"""Launch terminal with command using unified script-first approach
|
|
1208
1384
|
Args:
|
|
1209
1385
|
command: Command string from build_claude_command
|
|
@@ -1265,9 +1441,9 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1265
1441
|
return str(log_file)
|
|
1266
1442
|
|
|
1267
1443
|
# 3) Terminal modes
|
|
1268
|
-
terminal_mode =
|
|
1444
|
+
terminal_mode = get_config().terminal
|
|
1269
1445
|
|
|
1270
|
-
if terminal_mode == '
|
|
1446
|
+
if terminal_mode == 'print':
|
|
1271
1447
|
# Print script path and contents
|
|
1272
1448
|
try:
|
|
1273
1449
|
with open(script_file, 'r', encoding='utf-8') as f:
|
|
@@ -1280,7 +1456,7 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1280
1456
|
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
1281
1457
|
return False
|
|
1282
1458
|
|
|
1283
|
-
if terminal_mode == '
|
|
1459
|
+
if terminal_mode == 'here':
|
|
1284
1460
|
print("Launching Claude in current terminal...")
|
|
1285
1461
|
if IS_WINDOWS:
|
|
1286
1462
|
bash_exe = find_bash_on_windows()
|
|
@@ -1292,10 +1468,11 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1292
1468
|
result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
|
|
1293
1469
|
return result.returncode == 0
|
|
1294
1470
|
|
|
1295
|
-
# 4) New window mode
|
|
1296
|
-
|
|
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
|
|
1297
1474
|
|
|
1298
|
-
if not custom_cmd: #
|
|
1475
|
+
if not custom_cmd: # Platform default 'new' mode
|
|
1299
1476
|
if is_termux():
|
|
1300
1477
|
# Keep Termux as special case
|
|
1301
1478
|
am_cmd = [
|
|
@@ -1361,18 +1538,30 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1361
1538
|
print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
|
|
1362
1539
|
return False
|
|
1363
1540
|
|
|
1364
|
-
def setup_hooks():
|
|
1365
|
-
"""Set up Claude hooks in
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
settings_path = claude_dir / 'settings.local.json'
|
|
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
|
|
1370
1545
|
try:
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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 = {}
|
|
1376
1565
|
except (json.JSONDecodeError, PermissionError) as e:
|
|
1377
1566
|
raise Exception(format_error(f"Cannot read settings: {e}"))
|
|
1378
1567
|
|
|
@@ -1422,9 +1611,8 @@ def setup_hooks():
|
|
|
1422
1611
|
if 'env' not in settings:
|
|
1423
1612
|
settings['env'] = {}
|
|
1424
1613
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
settings['env']['HCOM'] = f'{python_path} {script_path}'
|
|
1614
|
+
# Set HCOM based on current execution context (uvx, hcom binary, or full path)
|
|
1615
|
+
settings['env']['HCOM'] = _build_hcom_env_value()
|
|
1428
1616
|
|
|
1429
1617
|
# Write settings atomically
|
|
1430
1618
|
try:
|
|
@@ -1438,37 +1626,31 @@ def setup_hooks():
|
|
|
1438
1626
|
|
|
1439
1627
|
return True
|
|
1440
1628
|
|
|
1441
|
-
def verify_hooks_installed(settings_path):
|
|
1629
|
+
def verify_hooks_installed(settings_path: Path) -> bool:
|
|
1442
1630
|
"""Verify that HCOM hooks were installed correctly with correct commands"""
|
|
1443
1631
|
try:
|
|
1444
|
-
settings =
|
|
1445
|
-
settings_path,
|
|
1446
|
-
lambda f: json.load(f),
|
|
1447
|
-
default=None
|
|
1448
|
-
)
|
|
1632
|
+
settings = load_settings_json(settings_path, default=None)
|
|
1449
1633
|
if not settings:
|
|
1450
1634
|
return False
|
|
1451
1635
|
|
|
1452
|
-
# Check all hook types have correct commands
|
|
1636
|
+
# Check all hook types have correct commands (exactly one HCOM hook per type)
|
|
1453
1637
|
hooks = settings.get('hooks', {})
|
|
1454
1638
|
for hook_type, expected_cmd in zip(ACTIVE_HOOK_TYPES, HOOK_COMMANDS):
|
|
1455
1639
|
hook_matchers = hooks.get(hook_type, [])
|
|
1456
1640
|
if not hook_matchers:
|
|
1457
1641
|
return False
|
|
1458
1642
|
|
|
1459
|
-
#
|
|
1460
|
-
|
|
1643
|
+
# Count HCOM hooks for this type
|
|
1644
|
+
hcom_hook_count = 0
|
|
1461
1645
|
for matcher in hook_matchers:
|
|
1462
1646
|
for hook in matcher.get('hooks', []):
|
|
1463
1647
|
command = hook.get('command', '')
|
|
1464
1648
|
# Check for HCOM and the correct subcommand
|
|
1465
1649
|
if ('${HCOM}' in command or 'hcom' in command.lower()) and expected_cmd in command:
|
|
1466
|
-
|
|
1467
|
-
break
|
|
1468
|
-
if found_correct_cmd:
|
|
1469
|
-
break
|
|
1650
|
+
hcom_hook_count += 1
|
|
1470
1651
|
|
|
1471
|
-
|
|
1652
|
+
# Must have exactly one HCOM hook (not zero, not duplicates)
|
|
1653
|
+
if hcom_hook_count != 1:
|
|
1472
1654
|
return False
|
|
1473
1655
|
|
|
1474
1656
|
# Check that HCOM env var is set
|
|
@@ -1480,11 +1662,11 @@ def verify_hooks_installed(settings_path):
|
|
|
1480
1662
|
except Exception:
|
|
1481
1663
|
return False
|
|
1482
1664
|
|
|
1483
|
-
def is_interactive():
|
|
1665
|
+
def is_interactive() -> bool:
|
|
1484
1666
|
"""Check if running in interactive mode"""
|
|
1485
1667
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
1486
1668
|
|
|
1487
|
-
def get_archive_timestamp():
|
|
1669
|
+
def get_archive_timestamp() -> str:
|
|
1488
1670
|
"""Get timestamp for archive files"""
|
|
1489
1671
|
return datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
1490
1672
|
|
|
@@ -1602,17 +1784,24 @@ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
1602
1784
|
|
|
1603
1785
|
# Check timeout
|
|
1604
1786
|
age = now - last_status_time
|
|
1605
|
-
timeout = pos_data.get('wait_timeout',
|
|
1787
|
+
timeout = pos_data.get('wait_timeout', get_config().timeout)
|
|
1606
1788
|
if age > timeout:
|
|
1607
1789
|
return "inactive", "", "timeout"
|
|
1608
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
|
+
|
|
1609
1801
|
# Detect stale 'waiting' status - check heartbeat, not status timestamp
|
|
1610
|
-
if last_status == 'waiting':
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
if heartbeat_age > 2:
|
|
1614
|
-
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1615
|
-
return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
|
|
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"
|
|
1616
1805
|
|
|
1617
1806
|
# Format description with context if template has {}
|
|
1618
1807
|
if '{}' in desc_template and last_context:
|
|
@@ -1629,15 +1818,12 @@ def get_status_block(status_type: str) -> str:
|
|
|
1629
1818
|
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
1630
1819
|
return f"{text_color}{BOLD}{color} {symbol} {RESET}"
|
|
1631
1820
|
|
|
1632
|
-
def format_message_line(msg, truncate=False):
|
|
1821
|
+
def format_message_line(msg: dict[str, str], truncate: bool = False) -> str:
|
|
1633
1822
|
"""Format a message for display"""
|
|
1634
1823
|
time_obj = datetime.fromisoformat(msg['timestamp'])
|
|
1635
1824
|
time_str = time_obj.strftime("%H:%M")
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
sender_emoji = get_config_value('sender_emoji', '🐳')
|
|
1639
|
-
|
|
1640
|
-
display_name = f"{sender_emoji} {msg['from']}" if msg['from'] == sender_name else msg['from']
|
|
1825
|
+
|
|
1826
|
+
display_name = f"{SENDER_EMOJI} {msg['from']}" if msg['from'] == SENDER else msg['from']
|
|
1641
1827
|
|
|
1642
1828
|
if truncate:
|
|
1643
1829
|
sender = display_name[:10]
|
|
@@ -1646,7 +1832,7 @@ def format_message_line(msg, truncate=False):
|
|
|
1646
1832
|
else:
|
|
1647
1833
|
return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
|
|
1648
1834
|
|
|
1649
|
-
def show_recent_messages(messages, limit=None, truncate=False):
|
|
1835
|
+
def show_recent_messages(messages: list[dict[str, str]], limit: int | None = None, truncate: bool = False) -> None:
|
|
1650
1836
|
"""Show recent messages"""
|
|
1651
1837
|
if limit is None:
|
|
1652
1838
|
messages_to_show = messages
|
|
@@ -1658,14 +1844,14 @@ def show_recent_messages(messages, limit=None, truncate=False):
|
|
|
1658
1844
|
print(format_message_line(msg, truncate))
|
|
1659
1845
|
|
|
1660
1846
|
|
|
1661
|
-
def get_terminal_height():
|
|
1847
|
+
def get_terminal_height() -> int:
|
|
1662
1848
|
"""Get current terminal height"""
|
|
1663
1849
|
try:
|
|
1664
1850
|
return shutil.get_terminal_size().lines
|
|
1665
1851
|
except (AttributeError, OSError):
|
|
1666
1852
|
return 24
|
|
1667
1853
|
|
|
1668
|
-
def show_recent_activity_alt_screen(limit=None):
|
|
1854
|
+
def show_recent_activity_alt_screen(limit: int | None = None) -> None:
|
|
1669
1855
|
"""Show recent messages in alt screen format with dynamic height"""
|
|
1670
1856
|
if limit is None:
|
|
1671
1857
|
# Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
|
|
@@ -1677,7 +1863,7 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1677
1863
|
messages = parse_log_messages(log_file).messages
|
|
1678
1864
|
show_recent_messages(messages, limit, truncate=True)
|
|
1679
1865
|
|
|
1680
|
-
def should_show_in_watch(d):
|
|
1866
|
+
def should_show_in_watch(d: dict[str, Any]) -> bool:
|
|
1681
1867
|
"""Show only enabled instances by default"""
|
|
1682
1868
|
# Hide disabled instances
|
|
1683
1869
|
if not d.get('enabled', False):
|
|
@@ -1690,7 +1876,7 @@ def should_show_in_watch(d):
|
|
|
1690
1876
|
# Show all other instances (including 'closed' during transition)
|
|
1691
1877
|
return True
|
|
1692
1878
|
|
|
1693
|
-
def show_instances_by_directory():
|
|
1879
|
+
def show_instances_by_directory() -> None:
|
|
1694
1880
|
"""Show instances organized by their working directories"""
|
|
1695
1881
|
positions = load_all_positions()
|
|
1696
1882
|
if not positions:
|
|
@@ -1747,7 +1933,7 @@ def alt_screen_detailed_status_and_input() -> str:
|
|
|
1747
1933
|
|
|
1748
1934
|
return message
|
|
1749
1935
|
|
|
1750
|
-
def get_status_summary():
|
|
1936
|
+
def get_status_summary() -> str:
|
|
1751
1937
|
"""Get a one-line summary of all instance statuses"""
|
|
1752
1938
|
positions = load_all_positions()
|
|
1753
1939
|
if not positions:
|
|
@@ -1780,18 +1966,18 @@ def get_status_summary():
|
|
|
1780
1966
|
else:
|
|
1781
1967
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1782
1968
|
|
|
1783
|
-
def update_status(s):
|
|
1969
|
+
def update_status(s: str) -> None:
|
|
1784
1970
|
"""Update status line in place"""
|
|
1785
1971
|
sys.stdout.write("\r\033[K" + s)
|
|
1786
1972
|
sys.stdout.flush()
|
|
1787
1973
|
|
|
1788
|
-
def log_line_with_status(message, status):
|
|
1974
|
+
def log_line_with_status(message: str, status: str) -> None:
|
|
1789
1975
|
"""Print message and immediately restore status"""
|
|
1790
1976
|
sys.stdout.write("\r\033[K" + message + "\n")
|
|
1791
1977
|
sys.stdout.write("\033[K" + status)
|
|
1792
1978
|
sys.stdout.flush()
|
|
1793
1979
|
|
|
1794
|
-
def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
1980
|
+
def initialize_instance_in_position_file(instance_name: str, session_id: str | None = None) -> bool:
|
|
1795
1981
|
"""Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
|
|
1796
1982
|
try:
|
|
1797
1983
|
data = load_instance_position(instance_name)
|
|
@@ -1799,15 +1985,24 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1799
1985
|
# Determine default enabled state: True for hcom-launched, False for vanilla
|
|
1800
1986
|
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
1801
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
|
+
|
|
1802
1995
|
defaults = {
|
|
1803
|
-
"pos":
|
|
1996
|
+
"pos": initial_pos,
|
|
1997
|
+
"starting_pos": initial_pos,
|
|
1804
1998
|
"enabled": is_hcom_launched,
|
|
1805
1999
|
"directory": str(Path.cwd()),
|
|
1806
2000
|
"last_stop": 0,
|
|
1807
2001
|
"session_id": session_id or "",
|
|
1808
2002
|
"transcript_path": "",
|
|
1809
2003
|
"notification_message": "",
|
|
1810
|
-
"alias_announced": False
|
|
2004
|
+
"alias_announced": False,
|
|
2005
|
+
"tag": None
|
|
1811
2006
|
}
|
|
1812
2007
|
|
|
1813
2008
|
# Add missing fields (preserve existing)
|
|
@@ -1818,7 +2013,7 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1818
2013
|
except Exception:
|
|
1819
2014
|
return False
|
|
1820
2015
|
|
|
1821
|
-
def update_instance_position(instance_name, update_fields):
|
|
2016
|
+
def update_instance_position(instance_name: str, update_fields: dict[str, Any]) -> None:
|
|
1822
2017
|
"""Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
|
|
1823
2018
|
try:
|
|
1824
2019
|
data = load_instance_position(instance_name)
|
|
@@ -1837,7 +2032,7 @@ def update_instance_position(instance_name, update_fields):
|
|
|
1837
2032
|
else:
|
|
1838
2033
|
raise
|
|
1839
2034
|
|
|
1840
|
-
def enable_instance(instance_name):
|
|
2035
|
+
def enable_instance(instance_name: str) -> None:
|
|
1841
2036
|
"""Enable instance - clears all stop flags and enables Stop hook polling"""
|
|
1842
2037
|
update_instance_position(instance_name, {
|
|
1843
2038
|
'enabled': True,
|
|
@@ -1846,14 +2041,13 @@ def enable_instance(instance_name):
|
|
|
1846
2041
|
})
|
|
1847
2042
|
set_status(instance_name, 'started')
|
|
1848
2043
|
|
|
1849
|
-
def disable_instance(instance_name, force=False):
|
|
2044
|
+
def disable_instance(instance_name: str, force: bool = False) -> None:
|
|
1850
2045
|
"""Disable instance - stops Stop hook polling"""
|
|
1851
2046
|
updates = {
|
|
1852
2047
|
'enabled': False
|
|
1853
2048
|
}
|
|
1854
2049
|
if force:
|
|
1855
2050
|
updates['force_closed'] = True
|
|
1856
|
-
|
|
1857
2051
|
update_instance_position(instance_name, updates)
|
|
1858
2052
|
set_status(instance_name, 'force_stopped' if force else 'stopped')
|
|
1859
2053
|
|
|
@@ -1864,82 +2058,14 @@ def set_status(instance_name: str, status: str, context: str = ''):
|
|
|
1864
2058
|
'last_status_time': int(time.time()),
|
|
1865
2059
|
'last_status_context': context
|
|
1866
2060
|
})
|
|
1867
|
-
|
|
1868
|
-
def merge_instance_data(to_data, from_data):
|
|
1869
|
-
"""Merge instance data from from_data into to_data."""
|
|
1870
|
-
# Use current session_id from source (overwrites previous)
|
|
1871
|
-
to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
|
|
1872
|
-
|
|
1873
|
-
# Update transient fields from source
|
|
1874
|
-
to_data['transcript_path'] = from_data.get('transcript_path', to_data.get('transcript_path', ''))
|
|
1875
|
-
|
|
1876
|
-
# Preserve maximum position
|
|
1877
|
-
to_data['pos'] = max(to_data.get('pos', 0), from_data.get('pos', 0))
|
|
1878
|
-
|
|
1879
|
-
# Update directory to most recent
|
|
1880
|
-
to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
|
|
1881
|
-
|
|
1882
|
-
# Update heartbeat timestamp to most recent
|
|
1883
|
-
to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
|
|
1884
|
-
|
|
1885
|
-
# Merge new status fields - take most recent status event
|
|
1886
|
-
from_time = from_data.get('last_status_time', 0)
|
|
1887
|
-
to_time = to_data.get('last_status_time', 0)
|
|
1888
|
-
if from_time > to_time:
|
|
1889
|
-
to_data['last_status'] = from_data.get('last_status', '')
|
|
1890
|
-
to_data['last_status_time'] = from_time
|
|
1891
|
-
to_data['last_status_context'] = from_data.get('last_status_context', '')
|
|
1892
|
-
|
|
1893
|
-
# Preserve background mode if set
|
|
1894
|
-
to_data['background'] = to_data.get('background') or from_data.get('background')
|
|
1895
|
-
if from_data.get('background_log_file'):
|
|
1896
|
-
to_data['background_log_file'] = from_data['background_log_file']
|
|
1897
|
-
|
|
1898
|
-
return to_data
|
|
1899
|
-
|
|
1900
|
-
def merge_instance_immediately(from_name, to_name):
|
|
1901
|
-
"""Merge from_name into to_name with safety checks. Returns success message or error message."""
|
|
1902
|
-
if from_name == to_name:
|
|
1903
|
-
return ""
|
|
1904
|
-
|
|
1905
|
-
try:
|
|
1906
|
-
from_data = load_instance_position(from_name)
|
|
1907
|
-
to_data = load_instance_position(to_name)
|
|
1908
|
-
|
|
1909
|
-
# Check if target has recent activity (time-based check instead of PID)
|
|
1910
|
-
now = time.time()
|
|
1911
|
-
last_activity = max(
|
|
1912
|
-
to_data.get('last_stop', 0),
|
|
1913
|
-
to_data.get('last_status_time', 0)
|
|
1914
|
-
)
|
|
1915
|
-
time_since_activity = now - last_activity
|
|
1916
|
-
if time_since_activity < MERGE_ACTIVITY_THRESHOLD:
|
|
1917
|
-
return f"Cannot recover {to_name}: instance is active (activity {int(time_since_activity)}s ago)"
|
|
1918
|
-
|
|
1919
|
-
# Merge data using helper
|
|
1920
|
-
to_data = merge_instance_data(to_data, from_data)
|
|
1921
|
-
|
|
1922
|
-
# Save merged data - check for success
|
|
1923
|
-
if not save_instance_position(to_name, to_data):
|
|
1924
|
-
return f"Failed to save merged data for {to_name}"
|
|
1925
|
-
|
|
1926
|
-
# Cleanup source file only after successful save
|
|
1927
|
-
try:
|
|
1928
|
-
hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
|
|
1929
|
-
except (FileNotFoundError, PermissionError, OSError):
|
|
1930
|
-
pass # Non-critical if cleanup fails
|
|
1931
|
-
|
|
1932
|
-
return f"[SUCCESS] ✓ Recovered alias: {to_name}"
|
|
1933
|
-
except Exception:
|
|
1934
|
-
return f"Failed to recover alias: {to_name}"
|
|
1935
|
-
|
|
2061
|
+
log_hook_error('set_status', f'Setting status to {status} with context {context} for {instance_name}')
|
|
1936
2062
|
|
|
1937
2063
|
# ==================== Command Functions ====================
|
|
1938
2064
|
|
|
1939
|
-
def show_main_screen_header():
|
|
2065
|
+
def show_main_screen_header() -> list[dict[str, str]]:
|
|
1940
2066
|
"""Show header for main screen"""
|
|
1941
2067
|
sys.stdout.write("\033[2J\033[H")
|
|
1942
|
-
|
|
2068
|
+
|
|
1943
2069
|
log_file = hcom_path(LOG_FILE)
|
|
1944
2070
|
all_messages = []
|
|
1945
2071
|
if log_file.exists():
|
|
@@ -1950,183 +2076,7 @@ def show_main_screen_header():
|
|
|
1950
2076
|
|
|
1951
2077
|
return all_messages
|
|
1952
2078
|
|
|
1953
|
-
def
|
|
1954
|
-
"""Show CLI hints if configured"""
|
|
1955
|
-
cli_hints = get_config_value('cli_hints', '')
|
|
1956
|
-
if cli_hints:
|
|
1957
|
-
if to_stderr:
|
|
1958
|
-
print(f"\n{cli_hints}", file=sys.stderr)
|
|
1959
|
-
else:
|
|
1960
|
-
print(f"\n{cli_hints}")
|
|
1961
|
-
|
|
1962
|
-
# ==================== CLI Parsing Functions ====================
|
|
1963
|
-
|
|
1964
|
-
def parse_count(value: str) -> int:
|
|
1965
|
-
"""Parse and validate instance count"""
|
|
1966
|
-
try:
|
|
1967
|
-
number = int(value, 10)
|
|
1968
|
-
except ValueError as exc:
|
|
1969
|
-
raise argparse.ArgumentTypeError('Count must be an integer. Use -a/--agent for agent names.') from exc
|
|
1970
|
-
if number <= 0:
|
|
1971
|
-
raise argparse.ArgumentTypeError('Count must be positive.')
|
|
1972
|
-
if number > 100:
|
|
1973
|
-
raise argparse.ArgumentTypeError('Too many instances requested (max 100).')
|
|
1974
|
-
return number
|
|
1975
|
-
|
|
1976
|
-
def split_forwarded_args(argv: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
1977
|
-
"""Split arguments on -- separator for forwarding to claude"""
|
|
1978
|
-
if '--' not in argv:
|
|
1979
|
-
return list(argv), []
|
|
1980
|
-
idx = argv.index('--')
|
|
1981
|
-
return list(argv[:idx]), list(argv[idx + 1:])
|
|
1982
|
-
|
|
1983
|
-
def parse_open(namespace: argparse.Namespace, forwarded: list[str]) -> OpenCommand:
|
|
1984
|
-
"""Parse and validate open command arguments"""
|
|
1985
|
-
prefix = namespace.prefix
|
|
1986
|
-
if prefix and '|' in prefix:
|
|
1987
|
-
raise CLIError('Prefix cannot contain "|" characters.')
|
|
1988
|
-
|
|
1989
|
-
agents = namespace.agent or []
|
|
1990
|
-
count = namespace.count if namespace.count is not None else 1
|
|
1991
|
-
if not agents:
|
|
1992
|
-
agents = ['generic']
|
|
1993
|
-
|
|
1994
|
-
return OpenCommand(
|
|
1995
|
-
count=count,
|
|
1996
|
-
agents=agents,
|
|
1997
|
-
prefix=prefix,
|
|
1998
|
-
background=namespace.background,
|
|
1999
|
-
claude_args=forwarded,
|
|
2000
|
-
)
|
|
2001
|
-
|
|
2002
|
-
def parse_watch(namespace: argparse.Namespace) -> WatchCommand:
|
|
2003
|
-
"""Parse and validate watch command arguments"""
|
|
2004
|
-
wait_value = namespace.wait
|
|
2005
|
-
if wait_value is not None and wait_value < 0:
|
|
2006
|
-
raise CLIError('--wait expects a non-negative number of seconds.')
|
|
2007
|
-
|
|
2008
|
-
if wait_value is not None:
|
|
2009
|
-
return WatchCommand(mode='wait', wait_seconds=wait_value or 60)
|
|
2010
|
-
if namespace.logs:
|
|
2011
|
-
return WatchCommand(mode='logs', wait_seconds=None)
|
|
2012
|
-
if namespace.status:
|
|
2013
|
-
return WatchCommand(mode='status', wait_seconds=None)
|
|
2014
|
-
return WatchCommand(mode='interactive', wait_seconds=None)
|
|
2015
|
-
|
|
2016
|
-
def parse_stop(namespace: argparse.Namespace) -> StopCommand:
|
|
2017
|
-
"""Parse and validate stop command arguments"""
|
|
2018
|
-
target = namespace.target
|
|
2019
|
-
return StopCommand(
|
|
2020
|
-
target=target,
|
|
2021
|
-
close_all_hooks=namespace.all,
|
|
2022
|
-
force=namespace.force,
|
|
2023
|
-
_hcom_session=getattr(namespace, '_hcom_session', None),
|
|
2024
|
-
)
|
|
2025
|
-
|
|
2026
|
-
def parse_start(namespace: argparse.Namespace) -> StartCommand:
|
|
2027
|
-
"""Parse and validate start command arguments"""
|
|
2028
|
-
return StartCommand(
|
|
2029
|
-
target=namespace.target,
|
|
2030
|
-
_hcom_session=getattr(namespace, '_hcom_session', None),
|
|
2031
|
-
)
|
|
2032
|
-
|
|
2033
|
-
def parse_send(namespace: argparse.Namespace) -> SendCommand:
|
|
2034
|
-
"""Parse and validate send command arguments"""
|
|
2035
|
-
if namespace.resume and namespace.message:
|
|
2036
|
-
raise CLIError('Specify a resume alias or a message, not both.')
|
|
2037
|
-
session_id = getattr(namespace, '_hcom_session', None)
|
|
2038
|
-
if namespace.resume:
|
|
2039
|
-
return SendCommand(message=None, resume_alias=namespace.resume, _hcom_session=session_id)
|
|
2040
|
-
if namespace.message is None:
|
|
2041
|
-
raise CLIError('Message required (usage: hcom send "message").')
|
|
2042
|
-
return SendCommand(message=namespace.message, resume_alias=None, _hcom_session=session_id)
|
|
2043
|
-
|
|
2044
|
-
def build_parser() -> argparse.ArgumentParser:
|
|
2045
|
-
"""Build argparse parser for hcom commands"""
|
|
2046
|
-
parser = argparse.ArgumentParser(prog='hcom', add_help=False)
|
|
2047
|
-
subparsers = parser.add_subparsers(dest='command', required=True)
|
|
2048
|
-
|
|
2049
|
-
# Open command
|
|
2050
|
-
open_parser = subparsers.add_parser('open', add_help=False)
|
|
2051
|
-
open_parser.add_argument('count', nargs='?', type=parse_count, default=1)
|
|
2052
|
-
open_parser.add_argument('-a', '--agent', dest='agent', action='append')
|
|
2053
|
-
open_parser.add_argument('-t', '--prefix', dest='prefix')
|
|
2054
|
-
open_parser.add_argument('-p', '--background', action='store_true', dest='background')
|
|
2055
|
-
open_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2056
|
-
open_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2057
|
-
|
|
2058
|
-
# Watch command
|
|
2059
|
-
watch_parser = subparsers.add_parser('watch', add_help=False)
|
|
2060
|
-
group = watch_parser.add_mutually_exclusive_group()
|
|
2061
|
-
group.add_argument('--logs', action='store_true')
|
|
2062
|
-
group.add_argument('--status', action='store_true')
|
|
2063
|
-
group.add_argument('--wait', nargs='?', const=60, type=int, metavar='SEC')
|
|
2064
|
-
watch_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2065
|
-
watch_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2066
|
-
|
|
2067
|
-
# Stop command
|
|
2068
|
-
stop_parser = subparsers.add_parser('stop', add_help=False)
|
|
2069
|
-
stop_parser.add_argument('target', nargs='?')
|
|
2070
|
-
stop_parser.add_argument('--all', action='store_true')
|
|
2071
|
-
stop_parser.add_argument('--force', action='store_true')
|
|
2072
|
-
stop_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
|
|
2073
|
-
stop_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2074
|
-
stop_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2075
|
-
|
|
2076
|
-
# Start command
|
|
2077
|
-
start_parser = subparsers.add_parser('start', add_help=False)
|
|
2078
|
-
start_parser.add_argument('target', nargs='?')
|
|
2079
|
-
start_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
|
|
2080
|
-
start_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2081
|
-
start_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2082
|
-
|
|
2083
|
-
# Send command
|
|
2084
|
-
send_parser = subparsers.add_parser('send', add_help=False)
|
|
2085
|
-
send_parser.add_argument('message', nargs='?')
|
|
2086
|
-
send_parser.add_argument('--resume', metavar='ALIAS', help=argparse.SUPPRESS)
|
|
2087
|
-
send_parser.add_argument('--_hcom_session', help=argparse.SUPPRESS)
|
|
2088
|
-
send_parser.add_argument('--help', action='store_true', dest='help_flag')
|
|
2089
|
-
send_parser.add_argument('-h', action='store_true', dest='help_flag_short')
|
|
2090
|
-
|
|
2091
|
-
return parser
|
|
2092
|
-
|
|
2093
|
-
def dispatch(namespace: argparse.Namespace, forwarded: list[str]):
|
|
2094
|
-
"""Dispatch parsed arguments to appropriate command parser"""
|
|
2095
|
-
command = namespace.command
|
|
2096
|
-
if command == 'open':
|
|
2097
|
-
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2098
|
-
return cmd_help()
|
|
2099
|
-
return parse_open(namespace, forwarded)
|
|
2100
|
-
if command == 'watch':
|
|
2101
|
-
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2102
|
-
return cmd_help()
|
|
2103
|
-
return parse_watch(namespace)
|
|
2104
|
-
if command == 'stop':
|
|
2105
|
-
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2106
|
-
return cmd_help()
|
|
2107
|
-
return parse_stop(namespace)
|
|
2108
|
-
if command == 'start':
|
|
2109
|
-
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2110
|
-
return cmd_help()
|
|
2111
|
-
return parse_start(namespace)
|
|
2112
|
-
if command == 'send':
|
|
2113
|
-
if getattr(namespace, 'help_flag', False) or getattr(namespace, 'help_flag_short', False):
|
|
2114
|
-
return cmd_help()
|
|
2115
|
-
return parse_send(namespace)
|
|
2116
|
-
raise CLIError(f'Unsupported command: {command}')
|
|
2117
|
-
|
|
2118
|
-
def needs_help(args: Sequence[str]) -> bool:
|
|
2119
|
-
"""Check if help was requested"""
|
|
2120
|
-
if not args:
|
|
2121
|
-
return True
|
|
2122
|
-
head = args[0]
|
|
2123
|
-
if head in {'help', '--help', '-h'}:
|
|
2124
|
-
return True
|
|
2125
|
-
return False
|
|
2126
|
-
|
|
2127
|
-
# ==================== Command Functions ====================
|
|
2128
|
-
|
|
2129
|
-
def cmd_help():
|
|
2079
|
+
def cmd_help() -> int:
|
|
2130
2080
|
"""Show help text"""
|
|
2131
2081
|
print(HELP_TEXT)
|
|
2132
2082
|
|
|
@@ -2150,15 +2100,14 @@ KEY UNDERSTANDING:
|
|
|
2150
2100
|
• hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
|
|
2151
2101
|
|
|
2152
2102
|
LAUNCH PATTERNS:
|
|
2153
|
-
hcom
|
|
2154
|
-
hcom
|
|
2155
|
-
hcom
|
|
2156
|
-
hcom
|
|
2157
|
-
hcom
|
|
2158
|
-
hcom
|
|
2159
|
-
hcom
|
|
2160
|
-
hcom
|
|
2161
|
-
HCOM_INITIAL_PROMPT="task" hcom open # Set initial prompt for instance
|
|
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
|
|
2162
2111
|
|
|
2163
2112
|
@MENTION TARGETING:
|
|
2164
2113
|
hcom send "message" # Broadcasts to everyone
|
|
@@ -2172,57 +2121,80 @@ STATUS INDICATORS:
|
|
|
2172
2121
|
• ○ inactive - timed out, disconnected, etc • ○ unknown
|
|
2173
2122
|
|
|
2174
2123
|
CONFIG:
|
|
2175
|
-
Config file
|
|
2124
|
+
Config file: ~/.hcom/config.env (KEY=VALUE format)
|
|
2176
2125
|
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
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)
|
|
2182
2132
|
|
|
2183
|
-
Temporary environment overrides for any setting (all caps & append HCOM_):
|
|
2184
|
-
HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages received by instance
|
|
2185
|
-
export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
|
|
2186
2133
|
|
|
2187
2134
|
EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
|
|
2188
2135
|
with 'hcom watch --status'. Instances respond automatically in shared chat.
|
|
2189
2136
|
|
|
2190
2137
|
Run 'claude --help' to see all claude code CLI flags.""")
|
|
2191
2138
|
|
|
2192
|
-
show_cli_hints(to_stderr=False)
|
|
2193
2139
|
else:
|
|
2194
2140
|
if not IS_WINDOWS:
|
|
2195
2141
|
print("\nFor additional info & examples: hcom --help | cat")
|
|
2196
2142
|
|
|
2197
2143
|
return 0
|
|
2198
2144
|
|
|
2199
|
-
def
|
|
2200
|
-
"""Launch Claude instances
|
|
2145
|
+
def cmd_launch(argv: list[str]) -> int:
|
|
2146
|
+
"""Launch Claude instances: hcom [N] [claude] [args]"""
|
|
2201
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
|
+
|
|
2202
2180
|
# Add -p flag and stream-json output for background mode if not already present
|
|
2203
|
-
claude_args =
|
|
2204
|
-
if
|
|
2181
|
+
claude_args = forwarded
|
|
2182
|
+
if background and '-p' not in claude_args and '--print' not in claude_args:
|
|
2205
2183
|
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
2206
2184
|
|
|
2207
|
-
terminal_mode =
|
|
2185
|
+
terminal_mode = get_config().terminal
|
|
2208
2186
|
|
|
2209
2187
|
# Calculate total instances to launch
|
|
2210
|
-
total_instances =
|
|
2188
|
+
total_instances = count * len(agents)
|
|
2211
2189
|
|
|
2212
|
-
# Fail fast for
|
|
2213
|
-
if terminal_mode == '
|
|
2190
|
+
# Fail fast for here mode with multiple instances
|
|
2191
|
+
if terminal_mode == 'here' and total_instances > 1:
|
|
2214
2192
|
print(format_error(
|
|
2215
|
-
f"
|
|
2216
|
-
"Use 'hcom
|
|
2193
|
+
f"'here' mode cannot launch {total_instances} instances (it's one terminal window)",
|
|
2194
|
+
"Use 'hcom 1' for one generic instance"
|
|
2217
2195
|
), file=sys.stderr)
|
|
2218
2196
|
return 1
|
|
2219
2197
|
|
|
2220
|
-
try:
|
|
2221
|
-
setup_hooks()
|
|
2222
|
-
except Exception as e:
|
|
2223
|
-
print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
|
|
2224
|
-
return 1
|
|
2225
|
-
|
|
2226
2198
|
log_file = hcom_path(LOG_FILE)
|
|
2227
2199
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2228
2200
|
|
|
@@ -2232,21 +2204,16 @@ def cmd_open(command: OpenCommand):
|
|
|
2232
2204
|
# Build environment variables for Claude instances
|
|
2233
2205
|
base_env = build_claude_env()
|
|
2234
2206
|
|
|
2235
|
-
# Add
|
|
2236
|
-
if
|
|
2237
|
-
base_env['
|
|
2238
|
-
send_cmd = build_send_command()
|
|
2239
|
-
hint = f"To respond to {command.prefix} group: {send_cmd} '@{command.prefix} message'"
|
|
2240
|
-
base_env['HCOM_INSTANCE_HINTS'] = hint
|
|
2241
|
-
first_use = f"You're in the {command.prefix} group. Use {command.prefix} to message: {send_cmd} '@{command.prefix} message'"
|
|
2242
|
-
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
2207
|
+
# Add tag-specific hints if provided
|
|
2208
|
+
if tag:
|
|
2209
|
+
base_env['HCOM_TAG'] = tag
|
|
2243
2210
|
|
|
2244
2211
|
launched = 0
|
|
2245
|
-
initial_prompt =
|
|
2212
|
+
initial_prompt = get_config().prompt
|
|
2246
2213
|
|
|
2247
2214
|
# Launch count instances of each agent
|
|
2248
|
-
for agent in
|
|
2249
|
-
for _ in range(
|
|
2215
|
+
for agent in agents:
|
|
2216
|
+
for _ in range(count):
|
|
2250
2217
|
instance_type = agent
|
|
2251
2218
|
instance_env = base_env.copy()
|
|
2252
2219
|
|
|
@@ -2254,7 +2221,7 @@ def cmd_open(command: OpenCommand):
|
|
|
2254
2221
|
instance_env['HCOM_LAUNCHED'] = '1'
|
|
2255
2222
|
|
|
2256
2223
|
# Mark background instances via environment with log filename
|
|
2257
|
-
if
|
|
2224
|
+
if background:
|
|
2258
2225
|
# Generate unique log filename
|
|
2259
2226
|
log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
|
|
2260
2227
|
instance_env['HCOM_BACKGROUND'] = log_filename
|
|
@@ -2292,7 +2259,7 @@ def cmd_open(command: OpenCommand):
|
|
|
2292
2259
|
continue
|
|
2293
2260
|
|
|
2294
2261
|
try:
|
|
2295
|
-
if
|
|
2262
|
+
if background:
|
|
2296
2263
|
log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
|
|
2297
2264
|
if log_file:
|
|
2298
2265
|
print(f"Background instance launched, log: {log_file}")
|
|
@@ -2316,37 +2283,31 @@ def cmd_open(command: OpenCommand):
|
|
|
2316
2283
|
else:
|
|
2317
2284
|
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
2318
2285
|
|
|
2319
|
-
# Auto-launch watch dashboard if
|
|
2320
|
-
terminal_mode =
|
|
2321
|
-
auto_watch = get_config_value('auto_watch', True)
|
|
2286
|
+
# Auto-launch watch dashboard if in new window mode (new or custom) and all instances launched successfully
|
|
2287
|
+
terminal_mode = get_config().terminal
|
|
2322
2288
|
|
|
2323
|
-
# Only auto-watch if ALL instances launched successfully
|
|
2324
|
-
if terminal_mode
|
|
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():
|
|
2325
2291
|
# Show tips first if needed
|
|
2326
|
-
if
|
|
2327
|
-
print(f"\n • Send to {
|
|
2292
|
+
if tag:
|
|
2293
|
+
print(f"\n • Send to {tag} team: hcom send '@{tag} message'")
|
|
2328
2294
|
|
|
2329
2295
|
# Clear transition message
|
|
2330
2296
|
print("\nOpening hcom watch...")
|
|
2331
2297
|
time.sleep(2) # Brief pause so user sees the message
|
|
2332
2298
|
|
|
2333
2299
|
# Launch interactive watch dashboard in current terminal
|
|
2334
|
-
|
|
2335
|
-
return cmd_watch(watch_cmd)
|
|
2300
|
+
return cmd_watch([]) # Empty argv = interactive mode
|
|
2336
2301
|
else:
|
|
2337
2302
|
tips = [
|
|
2338
2303
|
"Run 'hcom watch' to view/send in conversation dashboard",
|
|
2339
2304
|
]
|
|
2340
|
-
if
|
|
2341
|
-
tips.append(f"Send to {
|
|
2305
|
+
if tag:
|
|
2306
|
+
tips.append(f"Send to {tag} team: hcom send '@{tag} message'")
|
|
2342
2307
|
|
|
2343
2308
|
if tips:
|
|
2344
2309
|
print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
|
|
2345
2310
|
|
|
2346
|
-
# Show cli_hints if configured (non-interactive mode)
|
|
2347
|
-
if not is_interactive():
|
|
2348
|
-
show_cli_hints(to_stderr=False)
|
|
2349
|
-
|
|
2350
2311
|
return 0
|
|
2351
2312
|
|
|
2352
2313
|
except ValueError as e:
|
|
@@ -2356,20 +2317,45 @@ def cmd_open(command: OpenCommand):
|
|
|
2356
2317
|
print(str(e), file=sys.stderr)
|
|
2357
2318
|
return 1
|
|
2358
2319
|
|
|
2359
|
-
def cmd_watch(
|
|
2360
|
-
"""View conversation dashboard"""
|
|
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
|
+
|
|
2361
2352
|
log_file = hcom_path(LOG_FILE)
|
|
2362
2353
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2363
2354
|
|
|
2364
2355
|
if not log_file.exists() and not instances_dir.exists():
|
|
2365
|
-
print(format_error("No conversation log found", "Run 'hcom
|
|
2356
|
+
print(format_error("No conversation log found", "Run 'hcom' first"), file=sys.stderr)
|
|
2366
2357
|
return 1
|
|
2367
2358
|
|
|
2368
|
-
# Determine mode
|
|
2369
|
-
show_logs = command.mode in ('logs', 'wait')
|
|
2370
|
-
show_status = command.mode == 'status'
|
|
2371
|
-
wait_timeout = command.wait_seconds
|
|
2372
|
-
|
|
2373
2359
|
# Non-interactive mode (no TTY or flags specified)
|
|
2374
2360
|
if not is_interactive() or show_logs or show_status:
|
|
2375
2361
|
if show_logs:
|
|
@@ -2381,14 +2367,16 @@ def cmd_watch(command: WatchCommand):
|
|
|
2381
2367
|
last_pos = 0
|
|
2382
2368
|
messages = []
|
|
2383
2369
|
|
|
2384
|
-
# If --wait, show
|
|
2370
|
+
# If --wait, show recent messages (max of: last 3 messages OR all messages in last 5 seconds)
|
|
2385
2371
|
if wait_timeout is not None:
|
|
2386
2372
|
cutoff = datetime.now() - timedelta(seconds=5)
|
|
2387
|
-
|
|
2388
|
-
|
|
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
|
|
2389
2377
|
# Status to stderr, data to stdout
|
|
2390
2378
|
if recent_messages:
|
|
2391
|
-
print(f'---Showing
|
|
2379
|
+
print(f'---Showing recent messages---', file=sys.stderr)
|
|
2392
2380
|
for msg in recent_messages:
|
|
2393
2381
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2394
2382
|
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
@@ -2426,7 +2414,6 @@ def cmd_watch(command: WatchCommand):
|
|
|
2426
2414
|
else:
|
|
2427
2415
|
print("No messages yet", file=sys.stderr)
|
|
2428
2416
|
|
|
2429
|
-
show_cli_hints()
|
|
2430
2417
|
|
|
2431
2418
|
elif show_status:
|
|
2432
2419
|
# Build JSON output
|
|
@@ -2467,16 +2454,14 @@ def cmd_watch(command: WatchCommand):
|
|
|
2467
2454
|
}
|
|
2468
2455
|
|
|
2469
2456
|
print(json.dumps(output, indent=2))
|
|
2470
|
-
show_cli_hints()
|
|
2471
2457
|
else:
|
|
2472
2458
|
print("No TTY - Automation usage:", file=sys.stderr)
|
|
2473
|
-
print(" hcom send 'message' Send message to chat", file=sys.stderr)
|
|
2474
2459
|
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
2475
2460
|
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
2476
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)
|
|
2477
2463
|
print(" Full information: hcom --help")
|
|
2478
2464
|
|
|
2479
|
-
show_cli_hints()
|
|
2480
2465
|
|
|
2481
2466
|
return 0
|
|
2482
2467
|
|
|
@@ -2552,9 +2537,9 @@ def cmd_watch(command: WatchCommand):
|
|
|
2552
2537
|
last_pos = log_file.stat().st_size
|
|
2553
2538
|
|
|
2554
2539
|
if message and message.strip():
|
|
2555
|
-
|
|
2540
|
+
send_cli(message.strip(), quiet=True)
|
|
2556
2541
|
print(f"{FG_GREEN}✓ Sent{RESET}")
|
|
2557
|
-
|
|
2542
|
+
|
|
2558
2543
|
print()
|
|
2559
2544
|
|
|
2560
2545
|
current_status = get_status_summary()
|
|
@@ -2568,7 +2553,7 @@ def cmd_watch(command: WatchCommand):
|
|
|
2568
2553
|
|
|
2569
2554
|
return 0
|
|
2570
2555
|
|
|
2571
|
-
def
|
|
2556
|
+
def clear() -> int:
|
|
2572
2557
|
"""Clear and archive conversation"""
|
|
2573
2558
|
log_file = hcom_path(LOG_FILE)
|
|
2574
2559
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
@@ -2638,7 +2623,26 @@ def cmd_clear():
|
|
|
2638
2623
|
print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
|
|
2639
2624
|
return 1
|
|
2640
2625
|
|
|
2641
|
-
def
|
|
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]:
|
|
2642
2646
|
"""Remove hcom hooks from a specific directory
|
|
2643
2647
|
Returns tuple: (exit_code, message)
|
|
2644
2648
|
exit_code: 0 for success, 1 for error
|
|
@@ -2651,11 +2655,7 @@ def cleanup_directory_hooks(directory):
|
|
|
2651
2655
|
|
|
2652
2656
|
try:
|
|
2653
2657
|
# Load existing settings
|
|
2654
|
-
settings =
|
|
2655
|
-
settings_path,
|
|
2656
|
-
lambda f: json.load(f),
|
|
2657
|
-
default=None
|
|
2658
|
-
)
|
|
2658
|
+
settings = load_settings_json(settings_path, default=None)
|
|
2659
2659
|
if not settings:
|
|
2660
2660
|
return 1, "Cannot read Claude settings"
|
|
2661
2661
|
|
|
@@ -2692,51 +2692,40 @@ def cleanup_directory_hooks(directory):
|
|
|
2692
2692
|
return 1, format_error(f"Cannot modify settings.local.json: {e}")
|
|
2693
2693
|
|
|
2694
2694
|
|
|
2695
|
-
def cmd_stop(
|
|
2696
|
-
"""Stop instances
|
|
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
|
|
2697
2701
|
|
|
2698
|
-
#
|
|
2699
|
-
if
|
|
2700
|
-
|
|
2701
|
-
if
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
return cmd_cleanup()
|
|
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:]
|
|
2705
2708
|
|
|
2706
|
-
|
|
2707
|
-
|
|
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]
|
|
2709
2713
|
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
if positions:
|
|
2713
|
-
for instance_name in positions.keys():
|
|
2714
|
-
disable_instance(instance_name)
|
|
2715
|
-
print(f"Stopped HCOM for {len(positions)} instance(s)")
|
|
2716
|
-
|
|
2717
|
-
# Archive conversation
|
|
2718
|
-
clear_result = cmd_clear()
|
|
2719
|
-
|
|
2720
|
-
# Remove hooks from all directories
|
|
2721
|
-
cleanup_result = cmd_cleanup('--all')
|
|
2722
|
-
|
|
2723
|
-
return max(clear_result, cleanup_result)
|
|
2724
|
-
|
|
2725
|
-
elif command.target == 'all':
|
|
2726
|
-
# hcom stop all: stop all instances + archive
|
|
2714
|
+
# Handle 'all' target
|
|
2715
|
+
if target == 'all':
|
|
2727
2716
|
positions = load_all_positions()
|
|
2728
2717
|
|
|
2729
2718
|
if not positions:
|
|
2730
2719
|
print("No instances found")
|
|
2731
|
-
|
|
2732
|
-
return cmd_clear()
|
|
2720
|
+
return 0
|
|
2733
2721
|
|
|
2734
2722
|
stopped_count = 0
|
|
2735
2723
|
bg_logs = []
|
|
2724
|
+
stopped_names = []
|
|
2736
2725
|
for instance_name, instance_data in positions.items():
|
|
2737
2726
|
if instance_data.get('enabled', False):
|
|
2738
2727
|
disable_instance(instance_name)
|
|
2739
|
-
|
|
2728
|
+
stopped_names.append(instance_name)
|
|
2740
2729
|
stopped_count += 1
|
|
2741
2730
|
|
|
2742
2731
|
# Track background logs
|
|
@@ -2746,123 +2735,159 @@ def cmd_stop(command: StopCommand):
|
|
|
2746
2735
|
bg_logs.append((instance_name, log_file))
|
|
2747
2736
|
|
|
2748
2737
|
if stopped_count == 0:
|
|
2749
|
-
print("
|
|
2738
|
+
print("No instances to stop")
|
|
2750
2739
|
else:
|
|
2751
|
-
print(f"Stopped {stopped_count} instance(s)")
|
|
2740
|
+
print(f"Stopped {stopped_count} instance(s): {', '.join(stopped_names)}")
|
|
2752
2741
|
|
|
2753
2742
|
# Show background logs if any
|
|
2754
2743
|
if bg_logs:
|
|
2755
|
-
print(
|
|
2744
|
+
print()
|
|
2745
|
+
print("Background instance logs:")
|
|
2756
2746
|
for name, log_file in bg_logs:
|
|
2757
2747
|
print(f" {name}: {log_file}")
|
|
2758
|
-
print("\nMonitor: tail -f <log_file>")
|
|
2759
|
-
print("Force stop: hcom stop --force all")
|
|
2760
2748
|
|
|
2761
|
-
|
|
2762
|
-
return cmd_clear()
|
|
2749
|
+
return 0
|
|
2763
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)
|
|
2764
2755
|
else:
|
|
2765
|
-
|
|
2756
|
+
instance_name = target
|
|
2766
2757
|
|
|
2767
|
-
|
|
2768
|
-
if not check_and_update_hooks():
|
|
2769
|
-
return 1
|
|
2758
|
+
position = load_instance_position(instance_name) if instance_name else None
|
|
2770
2759
|
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
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)
|
|
2775
2764
|
else:
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
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
|
|
2779
2774
|
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2775
|
+
if not position:
|
|
2776
|
+
print(f"No instance found for {instance_name}")
|
|
2777
|
+
return 1
|
|
2783
2778
|
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
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
|
|
2787
2783
|
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
print(f"HCOM already stopped for {instance_name}")
|
|
2791
|
-
return 0
|
|
2784
|
+
# Disable instance (optionally with force)
|
|
2785
|
+
disable_instance(instance_name, force=force)
|
|
2792
2786
|
|
|
2793
|
-
|
|
2794
|
-
|
|
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.")
|
|
2795
2793
|
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
print(f"
|
|
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
2802
|
|
|
2803
|
-
|
|
2804
|
-
if position.get('background'):
|
|
2805
|
-
log_file = position.get('background_log_file', '')
|
|
2806
|
-
if log_file:
|
|
2807
|
-
print(f"\nBackground log: {log_file}")
|
|
2808
|
-
print(f"Monitor: tail -f {log_file}")
|
|
2809
|
-
if not command.force:
|
|
2810
|
-
print(f"Force stop: hcom stop --force {instance_name}")
|
|
2803
|
+
return 0
|
|
2811
2804
|
|
|
2812
|
-
|
|
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
|
|
2813
2810
|
|
|
2814
|
-
|
|
2815
|
-
|
|
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:]
|
|
2816
2817
|
|
|
2817
|
-
#
|
|
2818
|
-
if not
|
|
2819
|
-
|
|
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]
|
|
2820
2822
|
|
|
2821
2823
|
# Get instance name from injected session or target
|
|
2822
|
-
if
|
|
2823
|
-
instance_name, existing_data = resolve_instance_name(
|
|
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))
|
|
2824
2829
|
|
|
2825
2830
|
# Create instance if it doesn't exist (opt-in for vanilla instances)
|
|
2826
2831
|
if not existing_data:
|
|
2827
|
-
initialize_instance_in_position_file(instance_name,
|
|
2832
|
+
initialize_instance_in_position_file(instance_name, session_id)
|
|
2828
2833
|
# Enable instance (clears all stop flags)
|
|
2829
2834
|
enable_instance(instance_name)
|
|
2830
|
-
|
|
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})
|
|
2831
2844
|
else:
|
|
2832
2845
|
# Skip already started instances
|
|
2833
2846
|
if existing_data.get('enabled', False):
|
|
2834
2847
|
print(f"HCOM already started for {instance_name}")
|
|
2835
2848
|
return 0
|
|
2836
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
|
+
|
|
2837
2859
|
# Re-enabling existing instance
|
|
2838
2860
|
enable_instance(instance_name)
|
|
2839
|
-
|
|
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.")
|
|
2840
2867
|
|
|
2841
|
-
|
|
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})
|
|
2842
2872
|
|
|
2843
|
-
|
|
2844
|
-
if command.target == 'hooking':
|
|
2845
|
-
# hcom start hooking: install hooks in current directory
|
|
2846
|
-
if setup_hooks():
|
|
2847
|
-
print("HCOM hooks installed in current directory")
|
|
2848
|
-
print("Hooks active on next Claude Code launch in this directory")
|
|
2849
|
-
return 0
|
|
2850
|
-
else:
|
|
2851
|
-
return 1
|
|
2873
|
+
return 0
|
|
2852
2874
|
|
|
2853
2875
|
# CLI path: start specific instance
|
|
2854
2876
|
positions = load_all_positions()
|
|
2855
2877
|
|
|
2856
2878
|
# Handle missing target from external CLI
|
|
2857
|
-
if not
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
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)
|
|
2862
2887
|
return 1
|
|
2863
2888
|
|
|
2864
2889
|
# Start specific instance
|
|
2865
|
-
instance_name =
|
|
2890
|
+
instance_name = target
|
|
2866
2891
|
position = positions.get(instance_name)
|
|
2867
2892
|
|
|
2868
2893
|
if not position:
|
|
@@ -2874,17 +2899,82 @@ def cmd_start(command: StartCommand):
|
|
|
2874
2899
|
print(f"HCOM already started for {instance_name}")
|
|
2875
2900
|
return 0
|
|
2876
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
|
+
|
|
2877
2911
|
# Enable instance (clears all stop flags)
|
|
2878
2912
|
enable_instance(instance_name)
|
|
2879
2913
|
|
|
2880
2914
|
print(f"Started HCOM for {instance_name}. Rejoined chat.")
|
|
2881
2915
|
return 0
|
|
2882
2916
|
|
|
2883
|
-
def
|
|
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:
|
|
2884
2974
|
"""Remove hcom hooks from current directory or all directories"""
|
|
2885
2975
|
if args and args[0] == '--all':
|
|
2886
2976
|
directories = set()
|
|
2887
|
-
|
|
2977
|
+
|
|
2888
2978
|
# Get all directories from current instances
|
|
2889
2979
|
try:
|
|
2890
2980
|
positions = load_all_positions()
|
|
@@ -2894,6 +2984,24 @@ def cmd_cleanup(*args):
|
|
|
2894
2984
|
directories.add(instance_data['directory'])
|
|
2895
2985
|
except Exception as e:
|
|
2896
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}")
|
|
2897
3005
|
|
|
2898
3006
|
if not directories:
|
|
2899
3007
|
print("No directories found in current HCOM tracking")
|
|
@@ -2937,42 +3045,104 @@ def cmd_cleanup(*args):
|
|
|
2937
3045
|
print(message)
|
|
2938
3046
|
return exit_code
|
|
2939
3047
|
|
|
2940
|
-
def
|
|
2941
|
-
"""
|
|
2942
|
-
|
|
2943
|
-
if
|
|
2944
|
-
return
|
|
2945
|
-
|
|
2946
|
-
# Check both cwd and home for hooks
|
|
2947
|
-
cwd_settings = Path.cwd() / '.claude' / 'settings.local.json'
|
|
2948
|
-
home_settings = Path.home() / '.claude' / 'settings.local.json'
|
|
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
|
|
2949
3053
|
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
return
|
|
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
|
|
2953
3059
|
|
|
2954
|
-
|
|
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
|
|
2955
3067
|
try:
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
return False
|
|
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
|
|
2962
3073
|
|
|
2963
|
-
def
|
|
2964
|
-
"""
|
|
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
|
|
2965
3095
|
|
|
2966
|
-
#
|
|
2967
|
-
|
|
2968
|
-
|
|
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)
|
|
2969
3127
|
|
|
2970
|
-
|
|
2971
|
-
if command.resume_alias:
|
|
2972
|
-
caller_session = command._hcom_session # May be None for CLI
|
|
2973
|
-
return cmd_resume_merge(command.resume_alias, caller_session)
|
|
3128
|
+
return True
|
|
2974
3129
|
|
|
2975
|
-
|
|
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]
|
|
2976
3146
|
|
|
2977
3147
|
# Check message is provided
|
|
2978
3148
|
if not message:
|
|
@@ -2999,7 +3169,7 @@ def cmd_send(command: SendCommand, force_cli=False):
|
|
|
2999
3169
|
try:
|
|
3000
3170
|
positions = load_all_positions()
|
|
3001
3171
|
all_instances = list(positions.keys())
|
|
3002
|
-
sender_name =
|
|
3172
|
+
sender_name = SENDER
|
|
3003
3173
|
all_names = all_instances + [sender_name]
|
|
3004
3174
|
unmatched = [m for m in mentions
|
|
3005
3175
|
if not any(name.lower().startswith(m.lower()) for name in all_names)]
|
|
@@ -3009,19 +3179,17 @@ def cmd_send(command: SendCommand, force_cli=False):
|
|
|
3009
3179
|
pass # Don't fail on warning
|
|
3010
3180
|
|
|
3011
3181
|
# Determine sender from injected session_id or CLI
|
|
3012
|
-
if
|
|
3013
|
-
# Instance context -
|
|
3182
|
+
if session_id and not force_cli:
|
|
3183
|
+
# Instance context - resolve name from session_id (searches existing instances first)
|
|
3014
3184
|
try:
|
|
3015
|
-
sender_name =
|
|
3185
|
+
sender_name, instance_data = resolve_instance_name(session_id, get_config().tag)
|
|
3016
3186
|
except (ValueError, Exception) as e:
|
|
3017
3187
|
print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
|
|
3018
3188
|
return 1
|
|
3019
3189
|
|
|
3020
|
-
instance_data = load_instance_position(sender_name)
|
|
3021
|
-
|
|
3022
3190
|
# Initialize instance if doesn't exist (first use)
|
|
3023
3191
|
if not instance_data:
|
|
3024
|
-
initialize_instance_in_position_file(sender_name,
|
|
3192
|
+
initialize_instance_in_position_file(sender_name, session_id)
|
|
3025
3193
|
instance_data = load_instance_position(sender_name)
|
|
3026
3194
|
|
|
3027
3195
|
# Check force_closed
|
|
@@ -3042,88 +3210,40 @@ def cmd_send(command: SendCommand, force_cli=False):
|
|
|
3042
3210
|
# Show unread messages
|
|
3043
3211
|
messages = get_unread_messages(sender_name, update_position=True)
|
|
3044
3212
|
if messages:
|
|
3045
|
-
max_msgs =
|
|
3213
|
+
max_msgs = MAX_MESSAGES_PER_DELIVERY
|
|
3046
3214
|
formatted = format_hook_messages(messages[:max_msgs], sender_name)
|
|
3047
3215
|
print(f"Message sent\n\n{formatted}", file=sys.stderr)
|
|
3048
3216
|
else:
|
|
3049
3217
|
print("Message sent", file=sys.stderr)
|
|
3050
3218
|
|
|
3051
|
-
# Show cli_hints if configured (non-interactive mode)
|
|
3052
|
-
if not is_interactive():
|
|
3053
|
-
show_cli_hints()
|
|
3054
|
-
|
|
3055
3219
|
return 0
|
|
3056
3220
|
else:
|
|
3057
3221
|
# CLI context - no session_id or force_cli=True
|
|
3058
|
-
|
|
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
|
|
3059
3230
|
|
|
3060
3231
|
if not send_message(sender_name, message):
|
|
3061
3232
|
print(format_error("Failed to send message"), file=sys.stderr)
|
|
3062
3233
|
return 1
|
|
3063
3234
|
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
# Show cli_hints if configured (non-interactive mode)
|
|
3067
|
-
if not is_interactive():
|
|
3068
|
-
show_cli_hints()
|
|
3235
|
+
if not quiet:
|
|
3236
|
+
print(f"✓ Sent from {sender_name}", file=sys.stderr)
|
|
3069
3237
|
|
|
3070
3238
|
return 0
|
|
3071
3239
|
|
|
3072
|
-
def
|
|
3240
|
+
def send_cli(message: str, quiet: bool = False) -> int:
|
|
3073
3241
|
"""Force CLI sender (skip outbox, use config sender name)"""
|
|
3074
|
-
|
|
3075
|
-
return cmd_send(command, force_cli=True)
|
|
3076
|
-
|
|
3077
|
-
def cmd_resume_merge(alias: str, caller_session: str | None = None) -> int:
|
|
3078
|
-
"""Resume/merge current instance into an existing instance by alias.
|
|
3079
|
-
INTERNAL COMMAND: Only called via 'hcom send --resume alias' during implicit resume workflow.
|
|
3080
|
-
Not meant for direct CLI usage.
|
|
3081
|
-
Args:
|
|
3082
|
-
alias: Target instance alias to merge into
|
|
3083
|
-
caller_session: Session ID of caller (injected by PreToolUse hook) or None for CLI
|
|
3084
|
-
"""
|
|
3085
|
-
# If caller_session provided (from hcom send --resume), use it
|
|
3086
|
-
if caller_session:
|
|
3087
|
-
instance_name = get_display_name(caller_session)
|
|
3088
|
-
else:
|
|
3089
|
-
# CLI path - no session context
|
|
3090
|
-
print(format_error("Not in HCOM instance context"), file=sys.stderr)
|
|
3091
|
-
return 1
|
|
3092
|
-
|
|
3093
|
-
if not instance_name:
|
|
3094
|
-
print(format_error("Could not determine instance name"), file=sys.stderr)
|
|
3095
|
-
return 1
|
|
3096
|
-
|
|
3097
|
-
# Sanitize alias: must be valid instance name format
|
|
3098
|
-
# Base: 5 lowercase alphanumeric (e.g., hova3)
|
|
3099
|
-
# Prefixed: {prefix}-{5 chars} (e.g., api-hova3, cool-team-kolec)
|
|
3100
|
-
# This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
|
|
3101
|
-
if not re.match(r'^([a-zA-Z0-9_-]+-)?[a-z0-9]{5}$', alias):
|
|
3102
|
-
print(format_error("Invalid alias format. Must be 5-char instance name or prefix-name format"), file=sys.stderr)
|
|
3103
|
-
return 1
|
|
3104
|
-
|
|
3105
|
-
# Attempt to merge current instance into target alias
|
|
3106
|
-
status = merge_instance_immediately(instance_name, alias)
|
|
3107
|
-
|
|
3108
|
-
# Handle results
|
|
3109
|
-
if not status:
|
|
3110
|
-
# Empty status means names matched (from_name == to_name)
|
|
3111
|
-
status = f"[SUCCESS] ✓ Already using HCOM alias {alias}. Rejoined chat."
|
|
3112
|
-
elif status.startswith('[SUCCESS]'):
|
|
3113
|
-
# Merge successful - update message
|
|
3114
|
-
status = f"[SUCCESS] ✓ Resumed HCOM as {alias}. Rejoined chat."
|
|
3115
|
-
|
|
3116
|
-
# If merge successful, enable instance (clears session_ended and stop flags)
|
|
3117
|
-
if status.startswith('[SUCCESS]'):
|
|
3118
|
-
enable_instance(alias)
|
|
3119
|
-
|
|
3120
|
-
# Print status and return
|
|
3121
|
-
print(status, file=sys.stderr)
|
|
3122
|
-
return 0 if status.startswith('[SUCCESS]') else 1
|
|
3242
|
+
return cmd_send([message], force_cli=True, quiet=quiet)
|
|
3123
3243
|
|
|
3124
3244
|
# ==================== Hook Helpers ====================
|
|
3125
3245
|
|
|
3126
|
-
def format_hook_messages(messages, instance_name):
|
|
3246
|
+
def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
|
|
3127
3247
|
"""Format messages for hook feedback"""
|
|
3128
3248
|
if len(messages) == 1:
|
|
3129
3249
|
msg = messages[0]
|
|
@@ -3132,100 +3252,42 @@ def format_hook_messages(messages, instance_name):
|
|
|
3132
3252
|
parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
|
|
3133
3253
|
reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
|
|
3134
3254
|
|
|
3135
|
-
# Only append
|
|
3136
|
-
|
|
3137
|
-
if
|
|
3138
|
-
reason = f"{reason} | [{
|
|
3255
|
+
# Only append hints to messages
|
|
3256
|
+
hints = get_config().hints
|
|
3257
|
+
if hints:
|
|
3258
|
+
reason = f"{reason} | [{hints}]"
|
|
3139
3259
|
|
|
3140
3260
|
return reason
|
|
3141
3261
|
|
|
3142
3262
|
# ==================== Hook Handlers ====================
|
|
3143
3263
|
|
|
3144
|
-
def
|
|
3145
|
-
hook_type: str | None,
|
|
3146
|
-
session_id: str,
|
|
3147
|
-
source: str,
|
|
3148
|
-
existing_data: dict | None
|
|
3149
|
-
) -> SessionScenario | None:
|
|
3264
|
+
def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -> tuple[str, dict[str, Any], bool]:
|
|
3150
3265
|
"""
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
Returns:
|
|
3154
|
-
SessionScenario for definitive scenarios
|
|
3155
|
-
None for deferred decision (SessionStart with wrong session_id)
|
|
3156
|
-
"""
|
|
3157
|
-
if existing_data is not None:
|
|
3158
|
-
# Found existing instance with matching session_id
|
|
3159
|
-
return SessionScenario.MATCHED_RESUME
|
|
3160
|
-
|
|
3161
|
-
if hook_type == 'sessionstart' and source == 'resume':
|
|
3162
|
-
# SessionStart on resume without match = wrong session_id
|
|
3163
|
-
# Don't know if truly unmatched yet - UserPromptSubmit will decide
|
|
3164
|
-
return None # Deferred decision
|
|
3165
|
-
|
|
3166
|
-
if hook_type == 'userpromptsubmit' and source == 'resume':
|
|
3167
|
-
# UserPromptSubmit on resume without match = definitively unmatched
|
|
3168
|
-
return SessionScenario.UNMATCHED_RESUME
|
|
3169
|
-
|
|
3170
|
-
# Normal startup
|
|
3171
|
-
return SessionScenario.FRESH_START
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
def should_create_instance_file(scenario: SessionScenario | None, hook_type: str | None) -> bool:
|
|
3175
|
-
"""
|
|
3176
|
-
Decide whether to create instance file NOW.
|
|
3177
|
-
|
|
3178
|
-
Simplified: Only UserPromptSubmit creates instances.
|
|
3179
|
-
SessionStart just shows minimal message and tracks status.
|
|
3180
|
-
"""
|
|
3181
|
-
# Only UserPromptSubmit creates instances
|
|
3182
|
-
if hook_type != 'userpromptsubmit':
|
|
3183
|
-
return False
|
|
3184
|
-
|
|
3185
|
-
# Create for new scenarios only (not matched resume which already exists)
|
|
3186
|
-
return scenario in (SessionScenario.FRESH_START, SessionScenario.UNMATCHED_RESUME)
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
def init_hook_context(hook_data, hook_type=None):
|
|
3190
|
-
"""
|
|
3191
|
-
Initialize instance context with explicit scenario detection.
|
|
3192
|
-
|
|
3193
|
-
Flow:
|
|
3266
|
+
Initialize instance context. Flow:
|
|
3194
3267
|
1. Resolve instance name (search by session_id, generate if not found)
|
|
3195
|
-
2.
|
|
3196
|
-
3.
|
|
3197
|
-
4. Return
|
|
3268
|
+
2. Create instance file if fresh start in UserPromptSubmit
|
|
3269
|
+
3. Build updates dict
|
|
3270
|
+
4. Return (instance_name, updates, is_matched_resume)
|
|
3198
3271
|
"""
|
|
3199
3272
|
session_id = hook_data.get('session_id', '')
|
|
3200
3273
|
transcript_path = hook_data.get('transcript_path', '')
|
|
3201
|
-
|
|
3202
|
-
prefix = os.environ.get('HCOM_PREFIX')
|
|
3274
|
+
tag = get_config().tag
|
|
3203
3275
|
|
|
3204
|
-
#
|
|
3205
|
-
instance_name, existing_data = resolve_instance_name(session_id,
|
|
3276
|
+
# Resolve instance name - existing_data is None for fresh starts
|
|
3277
|
+
instance_name, existing_data = resolve_instance_name(session_id, tag)
|
|
3206
3278
|
|
|
3207
3279
|
# Save migrated data if we have it
|
|
3208
3280
|
if existing_data:
|
|
3209
3281
|
save_instance_position(instance_name, existing_data)
|
|
3210
3282
|
|
|
3211
|
-
#
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
# Check if instance is brand new (before creation - for bypass logic)
|
|
3215
|
-
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
3216
|
-
is_new_instance = not instance_file.exists()
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
# Step 3: Decide creation
|
|
3220
|
-
should_create = should_create_instance_file(scenario, hook_type)
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
if should_create:
|
|
3283
|
+
# Create instance file if fresh start in UserPromptSubmit
|
|
3284
|
+
if existing_data is None and hook_type == 'userpromptsubmit':
|
|
3224
3285
|
initialize_instance_in_position_file(instance_name, session_id)
|
|
3225
3286
|
|
|
3226
|
-
#
|
|
3287
|
+
# Build updates dict
|
|
3227
3288
|
updates: dict[str, Any] = {
|
|
3228
3289
|
'directory': str(Path.cwd()),
|
|
3290
|
+
'tag': tag,
|
|
3229
3291
|
}
|
|
3230
3292
|
|
|
3231
3293
|
if session_id:
|
|
@@ -3239,11 +3301,10 @@ def init_hook_context(hook_data, hook_type=None):
|
|
|
3239
3301
|
updates['background'] = True
|
|
3240
3302
|
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
3241
3303
|
|
|
3242
|
-
#
|
|
3243
|
-
|
|
3244
|
-
|
|
3304
|
+
# Simple boolean: matched resume if existing_data found
|
|
3305
|
+
is_matched_resume = (existing_data is not None)
|
|
3245
3306
|
|
|
3246
|
-
return instance_name, updates,
|
|
3307
|
+
return instance_name, updates, is_matched_resume
|
|
3247
3308
|
|
|
3248
3309
|
def pretooluse_decision(decision: str, reason: str) -> None:
|
|
3249
3310
|
"""Exit PreToolUse hook with permission decision"""
|
|
@@ -3257,7 +3318,7 @@ def pretooluse_decision(decision: str, reason: str) -> None:
|
|
|
3257
3318
|
print(json.dumps(output, ensure_ascii=False))
|
|
3258
3319
|
sys.exit(EXIT_SUCCESS)
|
|
3259
3320
|
|
|
3260
|
-
def handle_pretooluse(hook_data,
|
|
3321
|
+
def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
3261
3322
|
"""Handle PreToolUse hook - check force_closed, inject session_id"""
|
|
3262
3323
|
instance_data = load_instance_position(instance_name)
|
|
3263
3324
|
tool_name = hook_data.get('tool_name', '')
|
|
@@ -3271,18 +3332,16 @@ def handle_pretooluse(hook_data, instance_name, updates):
|
|
|
3271
3332
|
if instance_data.get('enabled', False):
|
|
3272
3333
|
set_status(instance_name, 'tool_pending', tool_name)
|
|
3273
3334
|
|
|
3274
|
-
# Inject session_id into hcom
|
|
3335
|
+
# Inject session_id into hcom commands via updatedInput
|
|
3275
3336
|
if tool_name == 'Bash' and session_id:
|
|
3276
3337
|
command = hook_data.get('tool_input', {}).get('command', '')
|
|
3277
3338
|
|
|
3278
|
-
# Match
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
# Inject session_id after EACH hcom command (handles chained commands)
|
|
3285
|
-
modified_command = re.sub(hcom_pattern, rf'\g<0> --_hcom_session {session_id}', command)
|
|
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)
|
|
3286
3345
|
|
|
3287
3346
|
output = {
|
|
3288
3347
|
"hookSpecificOutput": {
|
|
@@ -3298,13 +3357,12 @@ def handle_pretooluse(hook_data, instance_name, updates):
|
|
|
3298
3357
|
|
|
3299
3358
|
|
|
3300
3359
|
|
|
3301
|
-
def handle_stop(hook_data, instance_name, updates):
|
|
3360
|
+
def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3302
3361
|
"""Handle Stop hook - poll for messages and deliver"""
|
|
3303
3362
|
|
|
3304
3363
|
try:
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
timeout = get_config_value('wait_timeout', 1800)
|
|
3364
|
+
updates['last_stop'] = time.time()
|
|
3365
|
+
timeout = get_config().timeout
|
|
3308
3366
|
updates['wait_timeout'] = timeout
|
|
3309
3367
|
set_status(instance_name, 'waiting')
|
|
3310
3368
|
|
|
@@ -3316,39 +3374,57 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
3316
3374
|
start_time = time.time()
|
|
3317
3375
|
|
|
3318
3376
|
try:
|
|
3319
|
-
|
|
3377
|
+
first_poll = True
|
|
3320
3378
|
last_heartbeat = start_time
|
|
3321
3379
|
# Actual polling loop - this IS the holding pattern
|
|
3322
3380
|
while time.time() - start_time < timeout:
|
|
3323
|
-
if
|
|
3324
|
-
|
|
3325
|
-
loop_count += 1
|
|
3381
|
+
if first_poll:
|
|
3382
|
+
first_poll = False
|
|
3326
3383
|
|
|
3327
|
-
#
|
|
3384
|
+
# Reload instance data each poll iteration
|
|
3328
3385
|
instance_data = load_instance_position(instance_name)
|
|
3329
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
|
+
|
|
3330
3397
|
# Check if session ended (SessionEnd hook fired) - exit without changing status
|
|
3331
3398
|
if instance_data.get('session_ended'):
|
|
3332
3399
|
sys.exit(EXIT_SUCCESS) # Don't overwrite session_ended status
|
|
3333
3400
|
|
|
3334
|
-
# Check if user input is pending - exit cleanly if recent input
|
|
3401
|
+
# Check if user input is pending (timestamp fallback) - exit cleanly if recent input
|
|
3335
3402
|
last_user_input = instance_data.get('last_user_input', 0)
|
|
3336
3403
|
if time.time() - last_user_input < 0.2:
|
|
3337
3404
|
sys.exit(EXIT_SUCCESS) # Don't overwrite status - let current status remain
|
|
3338
3405
|
|
|
3339
|
-
# Check if
|
|
3406
|
+
# Check if stopped/disabled - exit cleanly
|
|
3340
3407
|
if not instance_data.get('enabled', False):
|
|
3341
3408
|
sys.exit(EXIT_SUCCESS) # Preserve 'stopped' status set by cmd_stop
|
|
3342
3409
|
|
|
3343
3410
|
# Check for new messages and deliver
|
|
3344
3411
|
if messages := get_unread_messages(instance_name, update_position=True):
|
|
3345
|
-
messages_to_show = messages[:
|
|
3412
|
+
messages_to_show = messages[:MAX_MESSAGES_PER_DELIVERY]
|
|
3346
3413
|
reason = format_hook_messages(messages_to_show, instance_name)
|
|
3347
3414
|
set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
|
|
3348
3415
|
|
|
3349
3416
|
output = {"decision": "block", "reason": reason}
|
|
3350
|
-
|
|
3351
|
-
|
|
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)
|
|
3352
3428
|
|
|
3353
3429
|
# Update heartbeat every 0.5 seconds for staleness detection
|
|
3354
3430
|
now = time.time()
|
|
@@ -3373,45 +3449,64 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
3373
3449
|
log_hook_error('handle_stop', e)
|
|
3374
3450
|
sys.exit(EXIT_SUCCESS) # Preserve previous status on exception
|
|
3375
3451
|
|
|
3376
|
-
def handle_notify(hook_data, instance_name, updates):
|
|
3452
|
+
def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3377
3453
|
"""Handle Notification hook - track permission requests"""
|
|
3378
3454
|
updates['notification_message'] = hook_data.get('message', '')
|
|
3379
3455
|
update_instance_position(instance_name, updates)
|
|
3380
3456
|
set_status(instance_name, 'blocked', hook_data.get('message', ''))
|
|
3381
3457
|
|
|
3382
|
-
def
|
|
3383
|
-
"""
|
|
3384
|
-
|
|
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')
|
|
3385
3461
|
|
|
3386
|
-
|
|
3387
|
-
|
|
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.
|
|
3388
3466
|
|
|
3389
|
-
|
|
3390
|
-
|
|
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)
|
|
3391
3474
|
|
|
3392
|
-
|
|
3393
|
-
|
|
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)
|
|
3394
3478
|
|
|
3395
3479
|
return int((time.time() - start) * 1000)
|
|
3396
3480
|
|
|
3397
|
-
def handle_userpromptsubmit(hook_data, instance_name, updates,
|
|
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:
|
|
3398
3482
|
"""Handle UserPromptSubmit hook - track when user sends messages"""
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
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
|
|
3404
3491
|
|
|
3405
3492
|
# Coordinate with Stop hook only if enabled AND Stop hook is active
|
|
3406
3493
|
stop_is_active = (time.time() - last_stop) < 1.0
|
|
3407
3494
|
|
|
3408
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)
|
|
3409
3505
|
updates['last_user_input'] = time.time()
|
|
3410
3506
|
update_instance_position(instance_name, updates)
|
|
3411
|
-
wait_ms = wait_for_stop_exit(instance_name)
|
|
3412
3507
|
|
|
3413
|
-
|
|
3414
|
-
|
|
3508
|
+
# Wait for Stop hook to delete flag file
|
|
3509
|
+
wait_for_stop_exit(instance_name)
|
|
3415
3510
|
|
|
3416
3511
|
# Build message based on what happened
|
|
3417
3512
|
msg = None
|
|
@@ -3419,18 +3514,8 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
|
|
|
3419
3514
|
# Determine if this is an HCOM-launched instance
|
|
3420
3515
|
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
3421
3516
|
|
|
3422
|
-
#
|
|
3423
|
-
|
|
3424
|
-
if is_new_instance:
|
|
3425
|
-
msg = build_hcom_bootstrap_text(instance_name)
|
|
3426
|
-
msg += (
|
|
3427
|
-
f"\n\n[CRITICAL: HCOM RESUME DETECTED - You MUST recover your identity to maintain conversation context. "
|
|
3428
|
-
f"Run this command: {resume_cmd} - This is REQUIRED for message history and position tracking]"
|
|
3429
|
-
)
|
|
3430
|
-
update_instance_position(instance_name, {'alias_announced': True})
|
|
3431
|
-
|
|
3432
|
-
# PRIORITY 2: Normal startup - show bootstrap if not already announced
|
|
3433
|
-
elif not alias_announced:
|
|
3517
|
+
# Show bootstrap if not already announced
|
|
3518
|
+
if not alias_announced:
|
|
3434
3519
|
if is_hcom_launched:
|
|
3435
3520
|
# HCOM-launched instance - show bootstrap immediately
|
|
3436
3521
|
msg = build_hcom_bootstrap_text(instance_name)
|
|
@@ -3445,8 +3530,8 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
|
|
|
3445
3530
|
msg += build_hcom_bootstrap_text(instance_name)
|
|
3446
3531
|
update_instance_position(instance_name, {'alias_announced': True})
|
|
3447
3532
|
|
|
3448
|
-
#
|
|
3449
|
-
if msg and
|
|
3533
|
+
# Add resume status note if we showed bootstrap for a matched resume
|
|
3534
|
+
if msg and is_matched_resume:
|
|
3450
3535
|
if is_enabled:
|
|
3451
3536
|
msg += "\n[Session resumed. HCOM started for this instance - will receive chat messages. Your alias and conversation history preserved.]"
|
|
3452
3537
|
else:
|
|
@@ -3454,34 +3539,48 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
|
|
|
3454
3539
|
|
|
3455
3540
|
if msg:
|
|
3456
3541
|
output = {
|
|
3457
|
-
# "systemMessage": "HCOM enabled",
|
|
3458
3542
|
"hookSpecificOutput": {
|
|
3459
3543
|
"hookEventName": "UserPromptSubmit",
|
|
3460
3544
|
"additionalContext": msg
|
|
3461
3545
|
}
|
|
3462
3546
|
}
|
|
3463
3547
|
print(json.dumps(output), file=sys.stdout)
|
|
3464
|
-
# sys.exit(1)
|
|
3465
3548
|
|
|
3466
|
-
def handle_sessionstart(hook_data
|
|
3467
|
-
"""Handle SessionStart hook -
|
|
3468
|
-
|
|
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
|
|
3469
3554
|
|
|
3470
|
-
#
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
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 += """
|
|
3476
3572
|
|
|
3477
|
-
|
|
3478
|
-
|
|
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
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
3479
3582
|
|
|
3480
|
-
|
|
3481
|
-
if os.environ.get('HCOM_LAUNCHED') == '1' and source == 'startup':
|
|
3482
|
-
first_use_text = get_config_value('first_use_text', '')
|
|
3483
|
-
if first_use_text:
|
|
3484
|
-
help_text += f" [{first_use_text}]"
|
|
3583
|
+
"""
|
|
3485
3584
|
|
|
3486
3585
|
output = {
|
|
3487
3586
|
"hookSpecificOutput": {
|
|
@@ -3492,7 +3591,7 @@ def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
|
|
|
3492
3591
|
|
|
3493
3592
|
print(json.dumps(output))
|
|
3494
3593
|
|
|
3495
|
-
def handle_sessionend(hook_data, instance_name, updates):
|
|
3594
|
+
def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3496
3595
|
"""Handle SessionEnd hook - mark session as ended and set final status"""
|
|
3497
3596
|
reason = hook_data.get('reason', 'unknown')
|
|
3498
3597
|
|
|
@@ -3507,6 +3606,35 @@ def handle_sessionend(hook_data, instance_name, updates):
|
|
|
3507
3606
|
except Exception as e:
|
|
3508
3607
|
log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
|
|
3509
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
|
+
|
|
3510
3638
|
def handle_hook(hook_type: str) -> None:
|
|
3511
3639
|
"""Unified hook handler for all HCOM hooks"""
|
|
3512
3640
|
hook_data = json.load(sys.stdin)
|
|
@@ -3515,130 +3643,99 @@ def handle_hook(hook_type: str) -> None:
|
|
|
3515
3643
|
log_hook_error('handle_hook', Exception('Failed to create directories'))
|
|
3516
3644
|
sys.exit(EXIT_SUCCESS)
|
|
3517
3645
|
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
# Exit early if no instance file exists, except:
|
|
3523
|
-
# - PreToolUse (handles first send opt-in)
|
|
3524
|
-
# - UserPromptSubmit with hcom command (shows preemptive bootstrap)
|
|
3525
|
-
if hook_type != 'pre' and os.environ.get('HCOM_LAUNCHED') != '1':
|
|
3526
|
-
session_id = hook_data.get('session_id', '')
|
|
3527
|
-
if not session_id:
|
|
3528
|
-
sys.exit(EXIT_SUCCESS)
|
|
3529
|
-
|
|
3530
|
-
instance_name = get_display_name(session_id, os.environ.get('HCOM_PREFIX'))
|
|
3531
|
-
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
3646
|
+
# SessionStart is standalone - no instance files
|
|
3647
|
+
if hook_type == 'sessionstart':
|
|
3648
|
+
handle_sessionstart(hook_data)
|
|
3649
|
+
sys.exit(EXIT_SUCCESS)
|
|
3532
3650
|
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
user_prompt = hook_data.get('prompt', '')
|
|
3537
|
-
if not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE):
|
|
3538
|
-
sys.exit(EXIT_SUCCESS)
|
|
3539
|
-
# Continue - let handle_userpromptsubmit show bootstrap
|
|
3540
|
-
else:
|
|
3541
|
-
sys.exit(EXIT_SUCCESS)
|
|
3651
|
+
# Vanilla instance check - exit early if should skip
|
|
3652
|
+
if should_skip_vanilla_instance(hook_type, hook_data):
|
|
3653
|
+
sys.exit(EXIT_SUCCESS)
|
|
3542
3654
|
|
|
3543
3655
|
# Initialize instance context (creates file if needed, reuses existing if session_id matches)
|
|
3544
|
-
instance_name, updates,
|
|
3545
|
-
|
|
3546
|
-
# Special bypass for unmatched resume - must show critical warning even if disabled
|
|
3547
|
-
# is_new_instance=True in UserPromptSubmit means SessionStart didn't create it (was skipped)
|
|
3548
|
-
if hook_type == 'userpromptsubmit' and is_new_instance:
|
|
3549
|
-
handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
|
|
3550
|
-
sys.exit(EXIT_SUCCESS)
|
|
3656
|
+
instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
|
|
3551
3657
|
|
|
3552
|
-
#
|
|
3658
|
+
# Load instance data once (for enabled check and to pass to handlers)
|
|
3659
|
+
instance_data = None
|
|
3553
3660
|
if hook_type != 'pre':
|
|
3554
3661
|
instance_data = load_instance_position(instance_name)
|
|
3555
|
-
|
|
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):
|
|
3556
3669
|
sys.exit(EXIT_SUCCESS)
|
|
3557
3670
|
|
|
3558
3671
|
match hook_type:
|
|
3559
3672
|
case 'pre':
|
|
3560
|
-
handle_pretooluse(hook_data, instance_name
|
|
3673
|
+
handle_pretooluse(hook_data, instance_name)
|
|
3561
3674
|
case 'poll':
|
|
3562
|
-
handle_stop(hook_data, instance_name, updates)
|
|
3675
|
+
handle_stop(hook_data, instance_name, updates, instance_data)
|
|
3563
3676
|
case 'notify':
|
|
3564
|
-
handle_notify(hook_data, instance_name, updates)
|
|
3677
|
+
handle_notify(hook_data, instance_name, updates, instance_data)
|
|
3565
3678
|
case 'userpromptsubmit':
|
|
3566
|
-
handle_userpromptsubmit(hook_data, instance_name, updates,
|
|
3567
|
-
case 'sessionstart':
|
|
3568
|
-
handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
|
|
3679
|
+
handle_userpromptsubmit(hook_data, instance_name, updates, is_matched_resume, instance_data)
|
|
3569
3680
|
case 'sessionend':
|
|
3570
|
-
handle_sessionend(hook_data, instance_name, updates)
|
|
3681
|
+
handle_sessionend(hook_data, instance_name, updates, instance_data)
|
|
3571
3682
|
|
|
3572
3683
|
sys.exit(EXIT_SUCCESS)
|
|
3573
3684
|
|
|
3574
3685
|
|
|
3575
3686
|
# ==================== Main Entry Point ====================
|
|
3576
3687
|
|
|
3577
|
-
def main(argv=None):
|
|
3688
|
+
def main(argv: list[str] | None = None) -> int | None:
|
|
3578
3689
|
"""Main command dispatcher"""
|
|
3579
3690
|
if argv is None:
|
|
3580
3691
|
argv = sys.argv[1:]
|
|
3581
3692
|
else:
|
|
3582
3693
|
argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
|
|
3583
3694
|
|
|
3584
|
-
#
|
|
3585
|
-
if needs_help(argv):
|
|
3586
|
-
return cmd_help()
|
|
3587
|
-
|
|
3588
|
-
# Handle hook commands (special case - no parsing needed)
|
|
3695
|
+
# Hook handlers only (called BY hooks, not users)
|
|
3589
3696
|
if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
|
|
3590
3697
|
handle_hook(argv[0])
|
|
3591
3698
|
return 0
|
|
3592
3699
|
|
|
3593
|
-
#
|
|
3594
|
-
|
|
3595
|
-
if len(argv) < 2:
|
|
3596
|
-
print(format_error("Message required"), file=sys.stderr)
|
|
3597
|
-
return 1
|
|
3598
|
-
return cmd_send_cli(argv[1])
|
|
3599
|
-
|
|
3600
|
-
# Split on -- separator for forwarding args
|
|
3601
|
-
hcom_args, forwarded = split_forwarded_args(argv)
|
|
3700
|
+
# Check for updates (CLI commands only, not hooks)
|
|
3701
|
+
check_version_once_daily()
|
|
3602
3702
|
|
|
3603
|
-
# Ensure directories exist
|
|
3604
|
-
if
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
return 1
|
|
3608
|
-
|
|
3609
|
-
# Build parser and parse arguments
|
|
3610
|
-
parser = build_parser()
|
|
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
|
|
3611
3707
|
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
except SystemExit as exc:
|
|
3615
|
-
if exc.code != 0:
|
|
3616
|
-
print("Run 'hcom -h' for help", file=sys.stderr)
|
|
3617
|
-
return exc.code if isinstance(exc.code, int) else 1
|
|
3708
|
+
# Ensure hooks current (warns but never blocks)
|
|
3709
|
+
ensure_hooks_current()
|
|
3618
3710
|
|
|
3619
|
-
#
|
|
3711
|
+
# Route to commands
|
|
3620
3712
|
try:
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
return
|
|
3630
|
-
elif
|
|
3631
|
-
return
|
|
3632
|
-
elif
|
|
3633
|
-
return cmd_stop(
|
|
3634
|
-
elif
|
|
3635
|
-
return cmd_start(
|
|
3636
|
-
elif
|
|
3637
|
-
return
|
|
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)
|
|
3638
3733
|
else:
|
|
3639
|
-
print(format_error(
|
|
3734
|
+
print(format_error(
|
|
3735
|
+
f"Unknown command: {argv[0]}",
|
|
3736
|
+
"Run 'hcom --help' for usage"
|
|
3737
|
+
), file=sys.stderr)
|
|
3640
3738
|
return 1
|
|
3641
|
-
|
|
3642
3739
|
except CLIError as exc:
|
|
3643
3740
|
print(str(exc), file=sys.stderr)
|
|
3644
3741
|
return 1
|