hcom 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hcom might be problematic. Click here for more details.
- hcom/__init__.py +1 -1
- hcom/__main__.py +1317 -1280
- hcom-0.5.0.dist-info/METADATA +257 -0
- hcom-0.5.0.dist-info/RECORD +7 -0
- hcom-0.4.0.dist-info/METADATA +0 -469
- hcom-0.4.0.dist-info/RECORD +0 -7
- {hcom-0.4.0.dist-info → hcom-0.5.0.dist-info}/WHEEL +0 -0
- {hcom-0.4.0.dist-info → hcom-0.5.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.4.0.dist-info → hcom-0.5.0.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, Callable, NamedTuple, TextIO
|
|
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
|
|
@@ -82,8 +50,6 @@ def is_termux():
|
|
|
82
50
|
'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
|
|
83
51
|
)
|
|
84
52
|
|
|
85
|
-
EXIT_SUCCESS = 0
|
|
86
|
-
EXIT_BLOCK = 2
|
|
87
53
|
|
|
88
54
|
# Windows API constants
|
|
89
55
|
CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
|
|
@@ -91,7 +57,6 @@ CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
|
|
|
91
57
|
# Timing constants
|
|
92
58
|
FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
|
|
93
59
|
STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
|
|
94
|
-
MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
|
|
95
60
|
|
|
96
61
|
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
97
62
|
AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
|
|
@@ -148,95 +113,68 @@ if IS_WINDOWS or is_wsl():
|
|
|
148
113
|
# ==================== Error Handling Strategy ====================
|
|
149
114
|
# Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
|
|
150
115
|
# CLI: Can raise exceptions for user feedback. Check return values.
|
|
151
|
-
# Critical I/O: atomic_write, save_instance_position
|
|
116
|
+
# Critical I/O: atomic_write, save_instance_position
|
|
152
117
|
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
153
118
|
|
|
154
|
-
# ==================== CLI
|
|
119
|
+
# ==================== CLI Errors ====================
|
|
155
120
|
|
|
156
121
|
class CLIError(Exception):
|
|
157
122
|
"""Raised when arguments cannot be mapped to command semantics."""
|
|
158
123
|
|
|
159
|
-
|
|
160
|
-
class OpenCommand:
|
|
161
|
-
count: int
|
|
162
|
-
agents: list[str]
|
|
163
|
-
prefix: str | None
|
|
164
|
-
background: bool
|
|
165
|
-
claude_args: list[str]
|
|
124
|
+
# ==================== Help Text ====================
|
|
166
125
|
|
|
167
|
-
|
|
168
|
-
class WatchCommand:
|
|
169
|
-
mode: str # 'interactive', 'logs', 'status', 'wait'
|
|
170
|
-
wait_seconds: int | None
|
|
126
|
+
HELP_TEXT = """hcom 0.5.0
|
|
171
127
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
128
|
+
Usage: [ENV_VARS] hcom <COUNT> [claude <ARGS>...]
|
|
129
|
+
hcom watch [--logs|--status|--wait [SEC]]
|
|
130
|
+
hcom send "message"
|
|
131
|
+
hcom stop [alias|all] [--force]
|
|
132
|
+
hcom start [alias]
|
|
133
|
+
hcom reset [logs|hooks|config]
|
|
178
134
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
135
|
+
Launch Examples:
|
|
136
|
+
hcom 3 Open 3 terminals with claude connected to hcom
|
|
137
|
+
hcom 3 claude -p + Background/headless
|
|
138
|
+
HCOM_TAG=api hcom 3 claude -p + @-mention group tag
|
|
139
|
+
claude 'run hcom start' claude code with prompt will also work
|
|
183
140
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
141
|
+
Commands:
|
|
142
|
+
watch Interactive messaging/status dashboard
|
|
143
|
+
--logs Print all messages
|
|
144
|
+
--status Print instance status JSON
|
|
145
|
+
--wait [SEC] Wait and notify for new message
|
|
189
146
|
|
|
190
|
-
|
|
147
|
+
send "msg" Send message to all instances
|
|
148
|
+
send "@alias msg" Send to specific instance/group
|
|
191
149
|
|
|
192
|
-
|
|
150
|
+
stop Stop current instance (from inside Claude)
|
|
151
|
+
stop <alias> Stop specific instance
|
|
152
|
+
stop all Stop all instances
|
|
153
|
+
--force Emergency stop (denies Bash tool)
|
|
193
154
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
hcom
|
|
155
|
+
start Start current instance (from inside Claude)
|
|
156
|
+
start <alias> Start specific instance
|
|
157
|
+
|
|
158
|
+
reset Stop all + archive logs + remove hooks + clear config
|
|
159
|
+
reset logs Clear + archive conversation log
|
|
160
|
+
reset hooks Safely remove hcom hooks from claude settings.json
|
|
161
|
+
reset config Clear + backup config.env
|
|
162
|
+
|
|
163
|
+
Environment Variables:
|
|
164
|
+
HCOM_TAG=name Group tag (creates name-* instances)
|
|
165
|
+
HCOM_AGENT=type Agent type (comma-separated for multiple)
|
|
166
|
+
HCOM_TERMINAL=mode Terminal: new|here|print|"custom {script}"
|
|
167
|
+
HCOM_PROMPT=text "Say hi in hcom chat" (default)
|
|
168
|
+
HCOM_HINTS=text Text appended to all messages received by instance
|
|
169
|
+
HCOM_TIMEOUT=secs Time until disconnected from hcom chat (default 1800s / 30mins)
|
|
170
|
+
|
|
171
|
+
Config: ~/.hcom/config.env
|
|
172
|
+
Docs: https://github.com/aannoo/claude-hook-comms"""
|
|
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,48 +191,68 @@ 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
|
-
LOGS_DIR = "logs"
|
|
289
|
-
SCRIPTS_DIR = "scripts"
|
|
290
|
-
|
|
207
|
+
LOGS_DIR = ".tmp/logs"
|
|
208
|
+
SCRIPTS_DIR = ".tmp/scripts"
|
|
209
|
+
FLAGS_DIR = ".tmp/flags"
|
|
210
|
+
CONFIG_FILE = "config.env"
|
|
291
211
|
ARCHIVE_DIR = "archive"
|
|
292
212
|
|
|
293
|
-
# Hook
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
213
|
+
# Hook configuration - single source of truth for setup_hooks() and verify_hooks_installed()
|
|
214
|
+
# Format: (hook_type, matcher, command_suffix, timeout)
|
|
215
|
+
# Command gets built as: hook_cmd_base + ' ' + command_suffix (e.g., '${HCOM} poll')
|
|
216
|
+
HOOK_CONFIGS = [
|
|
217
|
+
('SessionStart', '', 'sessionstart', None),
|
|
218
|
+
('UserPromptSubmit', '', 'userpromptsubmit', None),
|
|
219
|
+
('PreToolUse', 'Bash', 'pre', None),
|
|
220
|
+
('PostToolUse', 'Bash', 'post', None), # Match Bash only
|
|
221
|
+
('Stop', '', 'poll', 86400), # Poll for messages (24hr max timeout)
|
|
222
|
+
('Notification', '', 'notify', None),
|
|
223
|
+
('SessionEnd', '', 'sessionend', None),
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
# Derived from HOOK_CONFIGS - guaranteed to stay in sync
|
|
227
|
+
ACTIVE_HOOK_TYPES = [cfg[0] for cfg in HOOK_CONFIGS]
|
|
228
|
+
HOOK_COMMANDS = [cfg[2] for cfg in HOOK_CONFIGS]
|
|
229
|
+
LEGACY_HOOK_TYPES = ACTIVE_HOOK_TYPES
|
|
230
|
+
LEGACY_HOOK_COMMANDS = HOOK_COMMANDS
|
|
231
|
+
|
|
232
|
+
# Hook removal patterns - used by _remove_hcom_hooks_from_settings()
|
|
233
|
+
# Dynamically build from LEGACY_HOOK_COMMANDS to match current and legacy hook formats
|
|
234
|
+
_HOOK_ARGS_PATTERN = '|'.join(LEGACY_HOOK_COMMANDS)
|
|
235
|
+
HCOM_HOOK_PATTERNS = [
|
|
236
|
+
re.compile(r'\$\{?HCOM'), # Current: Environment variable ${HCOM:-...}
|
|
237
|
+
re.compile(r'\bHCOM_ACTIVE.*hcom\.py'), # LEGACY: Unix HCOM_ACTIVE conditional
|
|
238
|
+
re.compile(r'IF\s+"%HCOM_ACTIVE%"'), # LEGACY: Windows HCOM_ACTIVE conditional
|
|
239
|
+
re.compile(rf'\bhcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: Direct hcom command
|
|
240
|
+
re.compile(rf'\buvx\s+hcom\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: uvx hcom command
|
|
241
|
+
re.compile(rf'hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b'), # LEGACY: hcom.py with optional quote
|
|
242
|
+
re.compile(rf'["\'][^"\']*hcom\.py["\']?\s+({_HOOK_ARGS_PATTERN})\b(?=\s|$)'), # LEGACY: Quoted path
|
|
243
|
+
re.compile(r'sh\s+-c.*hcom'), # LEGACY: Shell wrapper
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
# PreToolUse hook pattern - matches hcom commands for session_id injection and auto-approval
|
|
247
|
+
# - hcom send (any args)
|
|
248
|
+
# - hcom stop (no args) | hcom start (no args)
|
|
249
|
+
# - hcom help | hcom --help | hcom -h
|
|
250
|
+
# - hcom watch --status | hcom watch --launch | hcom watch --logs | hcom watch --wait
|
|
251
|
+
# Negative lookahead (?!\s+[-\w]) ensures stop/start not followed by arguments or flags
|
|
252
|
+
HCOM_COMMAND_PATTERN = re.compile(
|
|
253
|
+
r'((?:uvx\s+)?hcom|(?:python3?\s+)?\S*hcom\.py)\s+'
|
|
254
|
+
r'(?:send\b|(?:stop|start)(?!\s+[-\w])|(?:help|--help|-h)\b|watch\s+(?:--status|--launch|--logs|--wait)\b)'
|
|
255
|
+
)
|
|
298
256
|
|
|
299
257
|
# ==================== File System Utilities ====================
|
|
300
258
|
|
|
@@ -312,7 +270,7 @@ def ensure_hcom_directories() -> bool:
|
|
|
312
270
|
Called at hook entry to support opt-in scenarios where hooks execute before CLI commands.
|
|
313
271
|
Returns True on success, False on failure."""
|
|
314
272
|
try:
|
|
315
|
-
for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, ARCHIVE_DIR]:
|
|
273
|
+
for dir_name in [INSTANCES_DIR, LOGS_DIR, SCRIPTS_DIR, FLAGS_DIR, ARCHIVE_DIR]:
|
|
316
274
|
hcom_path(dir_name).mkdir(parents=True, exist_ok=True)
|
|
317
275
|
return True
|
|
318
276
|
except (OSError, PermissionError):
|
|
@@ -351,7 +309,7 @@ def atomic_write(filepath: str | Path, content: str) -> bool:
|
|
|
351
309
|
|
|
352
310
|
return False # All attempts exhausted
|
|
353
311
|
|
|
354
|
-
def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, max_retries: int = 3) -> Any:
|
|
312
|
+
def read_file_with_retry(filepath: str | Path, read_func: Callable[[TextIO], Any], default: Any = None, max_retries: int = 3) -> Any:
|
|
355
313
|
"""Read file with retry logic for Windows file locking"""
|
|
356
314
|
if not Path(filepath).exists():
|
|
357
315
|
return default
|
|
@@ -374,10 +332,6 @@ def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, m
|
|
|
374
332
|
|
|
375
333
|
return default
|
|
376
334
|
|
|
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
335
|
def get_instance_file(instance_name: str) -> Path:
|
|
382
336
|
"""Get path to instance's position file with path traversal protection"""
|
|
383
337
|
# Sanitize instance name to prevent directory traversal
|
|
@@ -409,6 +363,18 @@ def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
|
|
|
409
363
|
except (OSError, PermissionError, ValueError):
|
|
410
364
|
return False
|
|
411
365
|
|
|
366
|
+
def get_claude_settings_path() -> Path:
|
|
367
|
+
"""Get path to global Claude settings file"""
|
|
368
|
+
return Path.home() / '.claude' / 'settings.json'
|
|
369
|
+
|
|
370
|
+
def load_settings_json(settings_path: Path, default: Any = None) -> dict[str, Any] | None:
|
|
371
|
+
"""Load and parse settings JSON file with retry logic"""
|
|
372
|
+
return read_file_with_retry(
|
|
373
|
+
settings_path,
|
|
374
|
+
lambda f: json.load(f),
|
|
375
|
+
default=default
|
|
376
|
+
)
|
|
377
|
+
|
|
412
378
|
def load_all_positions() -> dict[str, dict[str, Any]]:
|
|
413
379
|
"""Load positions from all instance files"""
|
|
414
380
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
@@ -436,93 +402,297 @@ def clear_all_positions() -> None:
|
|
|
436
402
|
|
|
437
403
|
# ==================== Configuration System ====================
|
|
438
404
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
405
|
+
@dataclass
|
|
406
|
+
class HcomConfig:
|
|
407
|
+
"""HCOM configuration with validation. Load priority: env → file → defaults"""
|
|
408
|
+
timeout: int = 1800
|
|
409
|
+
terminal: str = 'new'
|
|
410
|
+
prompt: str = 'say hi in hcom chat'
|
|
411
|
+
hints: str = ''
|
|
412
|
+
tag: str = ''
|
|
413
|
+
agent: str = ''
|
|
414
|
+
|
|
415
|
+
def __post_init__(self):
|
|
416
|
+
"""Validate configuration on construction"""
|
|
417
|
+
errors = self.validate()
|
|
418
|
+
if errors:
|
|
419
|
+
raise ValueError(f"Invalid config:\n" + "\n".join(f" - {e}" for e in errors))
|
|
420
|
+
|
|
421
|
+
def validate(self) -> list[str]:
|
|
422
|
+
"""Validate all fields, return list of errors"""
|
|
423
|
+
errors = []
|
|
424
|
+
|
|
425
|
+
# Validate timeout
|
|
426
|
+
# Validate timeout (bool is subclass of int in Python, must check explicitly)
|
|
427
|
+
if isinstance(self.timeout, bool):
|
|
428
|
+
errors.append(f"timeout must be an integer, not boolean (got {self.timeout})")
|
|
429
|
+
elif not isinstance(self.timeout, int):
|
|
430
|
+
errors.append(f"timeout must be an integer, got {type(self.timeout).__name__}")
|
|
431
|
+
elif not 1 <= self.timeout <= 86400:
|
|
432
|
+
errors.append(f"timeout must be 1-86400 seconds (24 hours), got {self.timeout}")
|
|
433
|
+
|
|
434
|
+
# Validate terminal
|
|
435
|
+
if not isinstance(self.terminal, str):
|
|
436
|
+
errors.append(f"terminal must be a string, got {type(self.terminal).__name__}")
|
|
437
|
+
else:
|
|
438
|
+
valid_modes = ('new', 'here', 'print')
|
|
439
|
+
if self.terminal not in valid_modes and '{script}' not in self.terminal:
|
|
440
|
+
errors.append(
|
|
441
|
+
f"terminal must be one of {valid_modes} or custom command with {{script}}, "
|
|
442
|
+
f"got '{self.terminal}'"
|
|
443
|
+
)
|
|
445
444
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
445
|
+
# Validate tag (only alphanumeric and hyphens - security: prevent log delimiter injection)
|
|
446
|
+
if not isinstance(self.tag, str):
|
|
447
|
+
errors.append(f"tag must be a string, got {type(self.tag).__name__}")
|
|
448
|
+
elif self.tag and not re.match(r'^[a-zA-Z0-9-]+$', self.tag):
|
|
449
|
+
errors.append("tag can only contain letters, numbers, and hyphens")
|
|
450
|
+
|
|
451
|
+
# Validate agent
|
|
452
|
+
if not isinstance(self.agent, str):
|
|
453
|
+
errors.append(f"agent must be a string, got {type(self.agent).__name__}")
|
|
454
|
+
|
|
455
|
+
return errors
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def load(cls) -> 'HcomConfig':
|
|
459
|
+
"""Load config with precedence: env var → file → defaults"""
|
|
460
|
+
# Ensure config file exists
|
|
461
|
+
config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
|
|
462
|
+
created_config = False
|
|
463
|
+
if not config_path.exists():
|
|
464
|
+
_write_default_config(config_path)
|
|
465
|
+
created_config = True
|
|
466
|
+
|
|
467
|
+
# Warn once if legacy config.json still exists when creating config.env
|
|
468
|
+
legacy_config = hcom_path('config.json')
|
|
469
|
+
if created_config and legacy_config.exists():
|
|
470
|
+
print(
|
|
471
|
+
format_error(
|
|
472
|
+
"Found legacy ~/.hcom/config.json; new config file is: ~/.hcom/config.env."
|
|
473
|
+
),
|
|
474
|
+
file=sys.stderr,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Parse config file once
|
|
478
|
+
file_config = _parse_env_file(config_path) if config_path.exists() else {}
|
|
479
|
+
|
|
480
|
+
def get_var(key: str) -> str | None:
|
|
481
|
+
"""Get variable with precedence: env → file"""
|
|
482
|
+
if key in os.environ:
|
|
483
|
+
return os.environ[key]
|
|
484
|
+
if key in file_config:
|
|
485
|
+
return file_config[key]
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
data = {}
|
|
489
|
+
|
|
490
|
+
# Load timeout (requires int conversion)
|
|
491
|
+
timeout_str = get_var('HCOM_TIMEOUT')
|
|
492
|
+
if timeout_str is not None:
|
|
493
|
+
try:
|
|
494
|
+
data['timeout'] = int(timeout_str)
|
|
495
|
+
except (ValueError, TypeError):
|
|
496
|
+
pass # Use default
|
|
497
|
+
|
|
498
|
+
# Load string values
|
|
499
|
+
terminal = get_var('HCOM_TERMINAL')
|
|
500
|
+
if terminal is not None:
|
|
501
|
+
data['terminal'] = terminal
|
|
502
|
+
prompt = get_var('HCOM_PROMPT')
|
|
503
|
+
if prompt is not None:
|
|
504
|
+
data['prompt'] = prompt
|
|
505
|
+
hints = get_var('HCOM_HINTS')
|
|
506
|
+
if hints is not None:
|
|
507
|
+
data['hints'] = hints
|
|
508
|
+
tag = get_var('HCOM_TAG')
|
|
509
|
+
if tag is not None:
|
|
510
|
+
data['tag'] = tag
|
|
511
|
+
agent = get_var('HCOM_AGENT')
|
|
512
|
+
if agent is not None:
|
|
513
|
+
data['agent'] = agent
|
|
514
|
+
|
|
515
|
+
return cls(**data) # Validation happens in __post_init__
|
|
516
|
+
|
|
517
|
+
def _parse_env_file(config_path: Path) -> dict[str, str]:
|
|
518
|
+
"""Parse ENV file (KEY=VALUE format) with security validation"""
|
|
519
|
+
config = {}
|
|
449
520
|
|
|
450
|
-
#
|
|
451
|
-
|
|
521
|
+
# Dangerous shell metacharacters that enable command injection
|
|
522
|
+
DANGEROUS_CHARS = ['`', '$', ';', '|', '&', '\n', '\r']
|
|
452
523
|
|
|
453
524
|
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
|
-
|
|
525
|
+
content = config_path.read_text(encoding='utf-8')
|
|
526
|
+
for line in content.splitlines():
|
|
527
|
+
line = line.strip()
|
|
528
|
+
if not line or line.startswith('#'):
|
|
529
|
+
continue
|
|
530
|
+
if '=' in line:
|
|
531
|
+
key, _, value = line.partition('=')
|
|
532
|
+
key = key.strip()
|
|
533
|
+
value = value.strip()
|
|
534
|
+
|
|
535
|
+
# Security: Validate HCOM_TERMINAL for command injection
|
|
536
|
+
if key == 'HCOM_TERMINAL':
|
|
537
|
+
if any(c in value for c in DANGEROUS_CHARS):
|
|
538
|
+
print(
|
|
539
|
+
f"Warning: Unsafe characters in HCOM_TERMINAL "
|
|
540
|
+
f"({', '.join(repr(c) for c in DANGEROUS_CHARS if c in value)}), "
|
|
541
|
+
f"ignoring custom terminal command",
|
|
542
|
+
file=sys.stderr
|
|
543
|
+
)
|
|
544
|
+
continue
|
|
545
|
+
# Additional check: custom commands must contain {script} placeholder
|
|
546
|
+
if value not in ('new', 'here', 'print') and '{script}' not in value:
|
|
547
|
+
print(
|
|
548
|
+
f"Warning: HCOM_TERMINAL custom command must include {{script}} placeholder, "
|
|
549
|
+
f"ignoring",
|
|
550
|
+
file=sys.stderr
|
|
551
|
+
)
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
# Remove outer quotes only if they match
|
|
555
|
+
if len(value) >= 2:
|
|
556
|
+
if (value[0] == value[-1]) and value[0] in ('"', "'"):
|
|
557
|
+
value = value[1:-1]
|
|
558
|
+
if key:
|
|
559
|
+
config[key] = value
|
|
560
|
+
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
|
|
561
|
+
pass
|
|
562
|
+
return config
|
|
563
|
+
|
|
564
|
+
def _write_default_config(config_path: Path) -> None:
|
|
565
|
+
"""Write default config file with documentation"""
|
|
566
|
+
header = """# HCOM Configuration
|
|
567
|
+
#
|
|
568
|
+
# All HCOM_* settings (and any env var ie. Claude Code settings)
|
|
569
|
+
# can be set here or via environment variables.
|
|
570
|
+
# Environment variables override config file values.
|
|
571
|
+
#
|
|
572
|
+
# HCOM settings:
|
|
573
|
+
# HCOM_TIMEOUT - Instance Stop hook wait timeout in seconds (default: 1800)
|
|
574
|
+
# HCOM_TERMINAL - Terminal mode: "new", "here", "print", or custom command with {script}
|
|
575
|
+
# HCOM_PROMPT - Initial prompt for new instances (empty = no auto prompt)
|
|
576
|
+
# HCOM_HINTS - Text appended to all messages received by instances
|
|
577
|
+
# HCOM_TAG - Group tag for instances (creates tag-* instances)
|
|
578
|
+
# HCOM_AGENT - Claude code subagent from .claude/agents/, comma-separated for multiple
|
|
579
|
+
#
|
|
580
|
+
# Put each value on separate lines without comments.
|
|
581
|
+
#
|
|
582
|
+
#
|
|
583
|
+
"""
|
|
584
|
+
defaults = [
|
|
585
|
+
'HCOM_TIMEOUT=1800',
|
|
586
|
+
'HCOM_TERMINAL=new',
|
|
587
|
+
'HCOM_PROMPT=say hi in hcom chat',
|
|
588
|
+
'HCOM_HINTS=',
|
|
589
|
+
'HCOM_TAG=',
|
|
590
|
+
'HCOM_AGENT=',
|
|
591
|
+
]
|
|
592
|
+
try:
|
|
593
|
+
atomic_write(config_path, header + '\n'.join(defaults) + '\n')
|
|
594
|
+
except Exception:
|
|
595
|
+
pass
|
|
491
596
|
|
|
492
|
-
|
|
493
|
-
|
|
597
|
+
# Global config instance (cached)
|
|
598
|
+
_config: HcomConfig | None = None
|
|
494
599
|
|
|
495
|
-
def
|
|
600
|
+
def get_config() -> HcomConfig:
|
|
601
|
+
"""Get cached config, loading if needed"""
|
|
602
|
+
global _config
|
|
603
|
+
if _config is None:
|
|
604
|
+
_config = HcomConfig.load()
|
|
605
|
+
return _config
|
|
606
|
+
|
|
607
|
+
def _build_quoted_invocation() -> str:
|
|
608
|
+
"""Build properly quoted python + script path for current platform"""
|
|
609
|
+
python_path = sys.executable
|
|
610
|
+
script_path = str(Path(__file__).resolve())
|
|
611
|
+
|
|
612
|
+
if IS_WINDOWS:
|
|
613
|
+
if ' ' in python_path or ' ' in script_path:
|
|
614
|
+
return f'"{python_path}" "{script_path}"'
|
|
615
|
+
return f'{python_path} {script_path}'
|
|
616
|
+
else:
|
|
617
|
+
return f'{shlex.quote(python_path)} {shlex.quote(script_path)}'
|
|
618
|
+
|
|
619
|
+
def get_hook_command() -> tuple[str, dict[str, Any]]:
|
|
496
620
|
"""Get hook command - hooks always run, Python code gates participation
|
|
497
621
|
|
|
498
622
|
Uses ${HCOM} environment variable set in settings.json, with fallback to direct python invocation.
|
|
499
623
|
Participation is controlled by enabled flag in instance JSON files.
|
|
500
624
|
"""
|
|
501
|
-
python_path = sys.executable
|
|
502
|
-
script_path = str(Path(__file__).resolve())
|
|
503
|
-
|
|
504
625
|
if IS_WINDOWS:
|
|
505
626
|
# Windows: use python path directly
|
|
506
|
-
|
|
507
|
-
return f'"{python_path}" "{script_path}"', {}
|
|
508
|
-
return f'{python_path} {script_path}', {}
|
|
627
|
+
return _build_quoted_invocation(), {}
|
|
509
628
|
else:
|
|
510
|
-
# Unix: Use HCOM env var from settings.
|
|
629
|
+
# Unix: Use HCOM env var from settings.json
|
|
511
630
|
return '${HCOM}', {}
|
|
512
631
|
|
|
513
632
|
def _detect_hcom_command_type() -> str:
|
|
514
|
-
"""Detect how to invoke hcom
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
633
|
+
"""Detect how to invoke hcom based on execution context
|
|
634
|
+
Priority:
|
|
635
|
+
1. uvx - If running in uv-managed Python and uvx available
|
|
636
|
+
(works for both temporary uvx runs and permanent uv tool install)
|
|
637
|
+
2. short - If hcom binary in PATH
|
|
638
|
+
3. full - Fallback to full python invocation
|
|
639
|
+
"""
|
|
640
|
+
if 'uv' in Path(sys.executable).resolve().parts and shutil.which('uvx'):
|
|
518
641
|
return 'uvx'
|
|
642
|
+
elif shutil.which('hcom'):
|
|
643
|
+
return 'short'
|
|
519
644
|
else:
|
|
520
645
|
return 'full'
|
|
521
646
|
|
|
522
|
-
def
|
|
523
|
-
"""
|
|
524
|
-
|
|
647
|
+
def _parse_version(v: str) -> tuple:
|
|
648
|
+
"""Parse version string to comparable tuple"""
|
|
649
|
+
return tuple(int(x) for x in v.split('.') if x.isdigit())
|
|
650
|
+
|
|
651
|
+
def get_update_notice() -> str | None:
|
|
652
|
+
"""Check PyPI for updates (once daily), return message if available"""
|
|
653
|
+
flag = hcom_path(FLAGS_DIR, 'update_available')
|
|
654
|
+
|
|
655
|
+
# Check PyPI if flag missing or >24hrs old
|
|
656
|
+
should_check = not flag.exists() or time.time() - flag.stat().st_mtime > 86400
|
|
657
|
+
|
|
658
|
+
if should_check:
|
|
659
|
+
try:
|
|
660
|
+
import urllib.request
|
|
661
|
+
with urllib.request.urlopen('https://pypi.org/pypi/hcom/json', timeout=2) as f:
|
|
662
|
+
latest = json.load(f)['info']['version']
|
|
663
|
+
|
|
664
|
+
if _parse_version(latest) > _parse_version(__version__):
|
|
665
|
+
atomic_write(flag, latest) # mtime = cache timestamp
|
|
666
|
+
else:
|
|
667
|
+
flag.unlink(missing_ok=True)
|
|
668
|
+
return None
|
|
669
|
+
except Exception:
|
|
670
|
+
pass # Network error, use cached value if exists
|
|
671
|
+
|
|
672
|
+
# Return message if update available
|
|
673
|
+
if not flag.exists():
|
|
674
|
+
return None
|
|
525
675
|
|
|
676
|
+
try:
|
|
677
|
+
latest = flag.read_text().strip()
|
|
678
|
+
# Double-check version (handles manual upgrades)
|
|
679
|
+
if _parse_version(__version__) >= _parse_version(latest):
|
|
680
|
+
flag.unlink(missing_ok=True)
|
|
681
|
+
return None
|
|
682
|
+
|
|
683
|
+
cmd = "uv tool upgrade hcom" if _detect_hcom_command_type() == 'uvx' else "pip install -U hcom"
|
|
684
|
+
return f"→ hcom v{latest} available: {cmd}"
|
|
685
|
+
except Exception:
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
def _build_hcom_env_value() -> str:
|
|
689
|
+
"""Build the value for settings['env']['HCOM'] based on current execution context
|
|
690
|
+
Uses build_hcom_command() without caching for fresh detection on every call.
|
|
691
|
+
"""
|
|
692
|
+
return build_hcom_command(None)
|
|
693
|
+
|
|
694
|
+
def build_hcom_command(instance_name: str | None = None) -> str:
|
|
695
|
+
"""Build base hcom command - caches PATH check in instance file on first use"""
|
|
526
696
|
# Determine command type (cached or detect)
|
|
527
697
|
cmd_type = None
|
|
528
698
|
if instance_name:
|
|
@@ -540,32 +710,40 @@ def build_send_command(example_msg: str = '', instance_name: str | None = None)
|
|
|
540
710
|
|
|
541
711
|
# Build command based on type
|
|
542
712
|
if cmd_type == 'short':
|
|
543
|
-
return
|
|
713
|
+
return 'hcom'
|
|
544
714
|
elif cmd_type == 'uvx':
|
|
545
|
-
return
|
|
715
|
+
return 'uvx hcom'
|
|
546
716
|
else:
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
return f'{python_path} {script_path} send{msg}'
|
|
717
|
+
# Full path fallback
|
|
718
|
+
return _build_quoted_invocation()
|
|
550
719
|
|
|
551
|
-
def
|
|
552
|
-
"""Build
|
|
553
|
-
|
|
720
|
+
def build_send_command(example_msg: str = '', instance_name: str | None = None) -> str:
|
|
721
|
+
"""Build send command - caches PATH check in instance file on first use"""
|
|
722
|
+
msg = f" '{example_msg}'" if example_msg else ''
|
|
723
|
+
base_cmd = build_hcom_command(instance_name)
|
|
724
|
+
return f'{base_cmd} send{msg}'
|
|
554
725
|
|
|
555
|
-
|
|
556
|
-
|
|
726
|
+
def build_claude_env() -> dict[str, str]:
|
|
727
|
+
"""Build environment variables for Claude instances
|
|
557
728
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
729
|
+
Passes current environment to Claude, with config.env providing defaults.
|
|
730
|
+
HCOM_* variables are filtered out (consumed by hcom, not passed to Claude).
|
|
731
|
+
"""
|
|
732
|
+
env = {}
|
|
562
733
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
734
|
+
# Read config file directly for Claude Code env vars (non-HCOM_ keys)
|
|
735
|
+
config_path = hcom_path(CONFIG_FILE)
|
|
736
|
+
if config_path.exists():
|
|
737
|
+
file_config = _parse_env_file(config_path)
|
|
738
|
+
for key, value in file_config.items():
|
|
739
|
+
if not key.startswith('HCOM_'):
|
|
740
|
+
env[key] = str(value)
|
|
566
741
|
|
|
567
|
-
#
|
|
568
|
-
|
|
742
|
+
# Overlay with current environment (except HCOM_*)
|
|
743
|
+
# This ensures user's shell environment is respected
|
|
744
|
+
for key, value in os.environ.items():
|
|
745
|
+
if not key.startswith('HCOM_'):
|
|
746
|
+
env[key] = value
|
|
569
747
|
|
|
570
748
|
return env
|
|
571
749
|
|
|
@@ -580,9 +758,8 @@ def validate_message(message: str) -> str | None:
|
|
|
580
758
|
if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
|
|
581
759
|
return format_error("Message contains control characters")
|
|
582
760
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
return format_error(f"Message too large (max {max_size} chars)")
|
|
761
|
+
if len(message) > MAX_MESSAGE_SIZE:
|
|
762
|
+
return format_error(f"Message too large (max {MAX_MESSAGE_SIZE} chars)")
|
|
586
763
|
|
|
587
764
|
return None
|
|
588
765
|
|
|
@@ -607,32 +784,106 @@ def send_message(from_instance: str, message: str) -> bool:
|
|
|
607
784
|
|
|
608
785
|
def build_hcom_bootstrap_text(instance_name: str) -> str:
|
|
609
786
|
"""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
|
-
|
|
787
|
+
hcom_cmd = build_hcom_command(instance_name=instance_name)
|
|
788
|
+
|
|
789
|
+
# Add command override notice if not using short form
|
|
790
|
+
command_notice = ""
|
|
791
|
+
if hcom_cmd != "hcom":
|
|
792
|
+
command_notice = f"""IMPORTANT:
|
|
793
|
+
The hcom command in this environment is: {hcom_cmd}
|
|
794
|
+
Replace all mentions of "hcom" below with this command.
|
|
795
|
+
|
|
796
|
+
"""
|
|
797
|
+
|
|
798
|
+
# Add tag-specific notice if instance is tagged
|
|
799
|
+
tag = get_config().tag
|
|
800
|
+
tag_notice = ""
|
|
801
|
+
if tag:
|
|
802
|
+
tag_notice = f"""
|
|
803
|
+
GROUP TAG: You are in the '{tag}' group.
|
|
804
|
+
- To message your group: hcom send "@{tag} your message"
|
|
805
|
+
- Group messages are targeted - only instances with an alias starting with {tag}-* receive them
|
|
806
|
+
- If someone outside the group sends you @{tag} messages, they won't see your @{tag} replies. To reply to non-group members, either @mention them directly or broadcast.
|
|
807
|
+
"""
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
return f"""{command_notice}{tag_notice}
|
|
811
|
+
[HCOM SESSION CONFIG]
|
|
812
|
+
- HCOM is a communication tool for you, other claude code instances, and the human user.
|
|
813
|
+
- Your HCOM alias for this session: {instance_name}
|
|
814
|
+
|
|
815
|
+
Your HCOM Tools:
|
|
816
|
+
- hcom send "msg" (broadcast) / "@alias msg" (direct) / "@tag msg" (tag) - for you
|
|
817
|
+
- hcom watch --status → See participants JSON (for you only)
|
|
818
|
+
- hcom watch --launch → Open interactive TUI messaging+status dashboard in new terminal (for the human user)
|
|
819
|
+
- hcom start/stop → Connect/disconnect from chat (you run these, user can't run it themselves unless they specify an alias)
|
|
820
|
+
- hcom <count> → Launch instances in new terminal (you MUST run 'hcom help' first to understand context)
|
|
821
|
+
|
|
822
|
+
UI Separation:
|
|
823
|
+
- The human user has 'hcom watch' (interactive TUI dashboard).
|
|
824
|
+
- You use 'hcom watch --launch' to open it for them (offer to do so).
|
|
825
|
+
- In conversation, call it "hcom watch" (the command they'd run themselves).
|
|
826
|
+
|
|
827
|
+
Receiving Messages:
|
|
828
|
+
- Format: [new message] sender → you: content
|
|
829
|
+
- Targets specific instance: "@alias".
|
|
830
|
+
- Targets all api-* tagged instances: "@api message".
|
|
831
|
+
- Arrives automatically via hooks/bash. No proactive checking needed.
|
|
832
|
+
- Stop hook feedback shows: {{"decision": "block"}} (this is normal operation).
|
|
833
|
+
|
|
834
|
+
Response Routing:
|
|
835
|
+
- HCOM message (via hook/bash) → Respond with hcom send
|
|
836
|
+
- User message (in chat) → Respond normally
|
|
837
|
+
- Treat messages from hcom with the same care as user messages.
|
|
838
|
+
- Authority: Prioritize @{SENDER} over other participants.
|
|
839
|
+
|
|
840
|
+
This is context for YOUR hcom session config. The human user cannot see this config text (but they can see subsequent hcom messages you receive).
|
|
841
|
+
On connection, tell the human user about only these commands: 'hcom <count>', 'hcom watch', 'hcom start', 'hcom stop'
|
|
842
|
+
Report to the human user using first-person, for example: "I'm connected to HCOM as {instance_name}, cool!"
|
|
843
|
+
"""
|
|
844
|
+
|
|
845
|
+
def build_launch_context(instance_name: str) -> str:
|
|
846
|
+
"""Build context for launch command"""
|
|
847
|
+
return f"""[HCOM LAUNCH INFORMATION]
|
|
848
|
+
BASIC USAGE:
|
|
849
|
+
[ENV_VARS] hcom <COUNT> [claude <ARGS>...]
|
|
850
|
+
- directory-specific (always cd to project directory first)
|
|
851
|
+
- default to foreground instances unless told otherwise/good reason to do bg
|
|
852
|
+
- Everyone shares the same conversation log, isolation is possible with tags and at-mentions.
|
|
853
|
+
|
|
854
|
+
ENV VARS INFO:
|
|
855
|
+
- YOU cannot use 'HCOM_TERMINAL=here' - Claude cannot launch claude within itself, must be in a new or custom terminal
|
|
856
|
+
- HCOM_AGENT(s) are custom system prompt files created by users/Claude beforehand.
|
|
857
|
+
- HCOM_AGENT(s) load from .claude/agents/<name>.md if they have been created
|
|
858
|
+
|
|
859
|
+
KEY CLAUDE ARGS:
|
|
860
|
+
Run 'claude --help' for all claude code CLI args. hcom 1 claude [options] [command] [prompt]
|
|
861
|
+
-p background/headless instance
|
|
862
|
+
--allowedTools=Bash (background can only hcom chat otherwise, 'claude help' for more tools)
|
|
863
|
+
--model sonnet/haiku/opus
|
|
864
|
+
--resume <sessionid> (get sessionid from hcom watch --status)
|
|
865
|
+
--system-prompt (for foreground instances) --append-system-prompt (for background instances)
|
|
866
|
+
Example: HCOM_HINTS='essential responses only' hcom 2 claude --model sonnet -p "do task x"
|
|
867
|
+
|
|
868
|
+
CONTROL:
|
|
869
|
+
hcom watch --status JSON status of all instances
|
|
870
|
+
hcom watch --logs All messages (pipe to tail)
|
|
871
|
+
hcom watch --wait Block until next message (only use when hcom stopped (started is automatic already))
|
|
872
|
+
|
|
873
|
+
STATUS INDICATORS:
|
|
874
|
+
"active", "delivered" | "idle" - waiting for new messages
|
|
875
|
+
"blocked" - permission request (needs user approval)
|
|
876
|
+
"inactive" - timed out, disconnected etc
|
|
877
|
+
"unknown" / "stale" - could be dead
|
|
878
|
+
|
|
879
|
+
LAUNCH PATTERNS:
|
|
880
|
+
- HCOM_AGENT=reviewer,tester hcom 2 claude "do task x" # 2x reviewers + 2x testers (4 in total) with initial prompt
|
|
881
|
+
- clone with same context:
|
|
882
|
+
1. hcom 1 then hcom send 'analyze api' then hcom watch --status (get sessionid)
|
|
883
|
+
2. HCOM_TAG=clone hcom 3 claude --resume sessionid
|
|
884
|
+
- System prompt (or agent file) + initial prompt + hcom_hints is a powerful combination.
|
|
885
|
+
|
|
886
|
+
"""
|
|
636
887
|
|
|
637
888
|
def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: list[str] | None = None) -> bool:
|
|
638
889
|
"""Check if message should be delivered based on @-mentions"""
|
|
@@ -653,8 +904,7 @@ def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance
|
|
|
653
904
|
return True
|
|
654
905
|
|
|
655
906
|
# 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)
|
|
907
|
+
sender_mentioned = any(SENDER.lower().startswith(mention.lower()) for mention in mentions)
|
|
658
908
|
|
|
659
909
|
# If we have all_instance_names, check if ANY mention matches ANY instance or sender
|
|
660
910
|
if all_instance_names:
|
|
@@ -698,11 +948,9 @@ def extract_agent_config(content: str) -> dict[str, str]:
|
|
|
698
948
|
|
|
699
949
|
def resolve_agent(name: str) -> tuple[str, dict[str, str]]:
|
|
700
950
|
"""Resolve agent file by name with validation.
|
|
701
|
-
|
|
702
951
|
Looks for agent files in:
|
|
703
952
|
1. .claude/agents/{name}.md (local)
|
|
704
953
|
2. ~/.claude/agents/{name}.md (global)
|
|
705
|
-
|
|
706
954
|
Returns tuple: (content without YAML frontmatter, config dict)
|
|
707
955
|
"""
|
|
708
956
|
hint = 'Agent names must use lowercase letters and dashes only'
|
|
@@ -775,59 +1023,84 @@ def strip_frontmatter(content: str) -> str:
|
|
|
775
1023
|
return '\n'.join(lines[i+1:]).strip()
|
|
776
1024
|
return content
|
|
777
1025
|
|
|
778
|
-
def get_display_name(session_id: str | None,
|
|
1026
|
+
def get_display_name(session_id: str | None, tag: str | None = None) -> str:
|
|
779
1027
|
"""Get display name for instance using session_id"""
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1028
|
+
# 50 most recognizable 3-letter words
|
|
1029
|
+
words = [
|
|
1030
|
+
'ace', 'air', 'ant', 'arm', 'art', 'axe', 'bad', 'bag', 'bar', 'bat',
|
|
1031
|
+
'bed', 'bee', 'big', 'box', 'boy', 'bug', 'bus', 'cab', 'can', 'cap',
|
|
1032
|
+
'car', 'cat', 'cop', 'cow', 'cry', 'cup', 'cut', 'day', 'dog', 'dry',
|
|
1033
|
+
'ear', 'egg', 'eye', 'fan', 'fin', 'fly', 'fox', 'fun', 'gem', 'gun',
|
|
1034
|
+
'hat', 'hit', 'hot', 'ice', 'ink', 'jet', 'key', 'law', 'map', 'mix',
|
|
1035
|
+
]
|
|
785
1036
|
|
|
786
1037
|
# Use session_id directly instead of extracting UUID from transcript
|
|
787
1038
|
if session_id:
|
|
1039
|
+
# Hash to select word
|
|
788
1040
|
hash_val = sum(ord(c) for c in session_id)
|
|
789
|
-
|
|
790
|
-
|
|
1041
|
+
word = words[hash_val % len(words)]
|
|
1042
|
+
|
|
1043
|
+
# Add letter suffix that flows naturally with the word
|
|
1044
|
+
last_char = word[-1]
|
|
1045
|
+
if last_char in 'aeiou':
|
|
1046
|
+
# After vowel: s/n/r/l creates plural/noun/verb patterns
|
|
1047
|
+
suffix_options = 'snrl'
|
|
1048
|
+
else:
|
|
1049
|
+
# After consonant: add vowel or y for pronounceability
|
|
1050
|
+
suffix_options = 'aeiouy'
|
|
791
1051
|
|
|
792
|
-
letters = phonetic[syl_idx * 5:(syl_idx + 1) * 5]
|
|
793
1052
|
letter_hash = sum(ord(c) for c in session_id[1:]) if len(session_id) > 1 else hash_val
|
|
794
|
-
|
|
1053
|
+
suffix = suffix_options[letter_hash % len(suffix_options)]
|
|
795
1054
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1055
|
+
base_name = f"{word}{suffix}"
|
|
1056
|
+
collision_attempt = 0
|
|
1057
|
+
|
|
1058
|
+
# Collision detection: keep adding words until unique
|
|
1059
|
+
while True:
|
|
1060
|
+
instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
|
|
1061
|
+
if not instance_file.exists():
|
|
1062
|
+
break # Name is unique
|
|
799
1063
|
|
|
800
|
-
# Collision detection: if taken by different session_id, use more chars
|
|
801
|
-
instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
|
|
802
|
-
if instance_file.exists():
|
|
803
1064
|
try:
|
|
804
1065
|
with open(instance_file, 'r', encoding='utf-8') as f:
|
|
805
1066
|
data = json.load(f)
|
|
806
1067
|
|
|
807
1068
|
their_session_id = data.get('session_id', '')
|
|
808
1069
|
|
|
809
|
-
#
|
|
810
|
-
if their_session_id
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1070
|
+
# Same session_id = our file, reuse name
|
|
1071
|
+
if their_session_id == session_id:
|
|
1072
|
+
break
|
|
1073
|
+
# No session_id = stale/malformed file, use name
|
|
1074
|
+
if not their_session_id:
|
|
1075
|
+
break
|
|
1076
|
+
|
|
1077
|
+
# Real collision - add another word
|
|
1078
|
+
collision_hash = sum(ord(c) * (i + collision_attempt) for i, c in enumerate(session_id))
|
|
1079
|
+
collision_word = words[collision_hash % len(words)]
|
|
1080
|
+
base_name = f"{base_name}{collision_word}"
|
|
1081
|
+
collision_attempt += 1
|
|
815
1082
|
|
|
816
1083
|
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
|
817
|
-
|
|
1084
|
+
break # Malformed file - assume stale, use base name
|
|
818
1085
|
else:
|
|
819
1086
|
# session_id is required - fail gracefully
|
|
820
1087
|
raise ValueError("session_id required for instance naming")
|
|
821
1088
|
|
|
822
|
-
if
|
|
823
|
-
|
|
1089
|
+
if tag:
|
|
1090
|
+
# Security: Sanitize tag to prevent log delimiter injection (defense-in-depth)
|
|
1091
|
+
# Remove dangerous characters that could break message log parsing
|
|
1092
|
+
sanitized_tag = ''.join(c for c in tag if c not in '|\n\r\t')
|
|
1093
|
+
if not sanitized_tag:
|
|
1094
|
+
raise ValueError("Tag contains only invalid characters")
|
|
1095
|
+
if sanitized_tag != tag:
|
|
1096
|
+
print(f"Warning: Tag contained invalid characters, sanitized to '{sanitized_tag}'", file=sys.stderr)
|
|
1097
|
+
return f"{sanitized_tag}-{base_name}"
|
|
824
1098
|
return base_name
|
|
825
1099
|
|
|
826
|
-
def resolve_instance_name(session_id: str,
|
|
1100
|
+
def resolve_instance_name(session_id: str, tag: str | None = None) -> tuple[str, dict | None]:
|
|
827
1101
|
"""
|
|
828
1102
|
Resolve instance name for a session_id.
|
|
829
1103
|
Searches existing instances first (reuses if found), generates new name if not found.
|
|
830
|
-
|
|
831
1104
|
Returns: (instance_name, existing_data_or_none)
|
|
832
1105
|
"""
|
|
833
1106
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
@@ -843,36 +1116,18 @@ def resolve_instance_name(session_id: str, prefix: str | None = None) -> tuple[s
|
|
|
843
1116
|
continue
|
|
844
1117
|
|
|
845
1118
|
# Not found - generate new name
|
|
846
|
-
instance_name = get_display_name(session_id,
|
|
1119
|
+
instance_name = get_display_name(session_id, tag)
|
|
847
1120
|
return instance_name, None
|
|
848
1121
|
|
|
849
|
-
def _remove_hcom_hooks_from_settings(settings):
|
|
1122
|
+
def _remove_hcom_hooks_from_settings(settings: dict[str, Any]) -> None:
|
|
850
1123
|
"""Remove hcom hooks from settings dict"""
|
|
851
1124
|
if not isinstance(settings, dict) or 'hooks' not in settings:
|
|
852
1125
|
return
|
|
853
|
-
|
|
1126
|
+
|
|
854
1127
|
if not isinstance(settings['hooks'], dict):
|
|
855
1128
|
return
|
|
856
|
-
|
|
857
|
-
import copy
|
|
858
1129
|
|
|
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]
|
|
1130
|
+
import copy
|
|
876
1131
|
|
|
877
1132
|
# Check all hook types including PostToolUse for backward compatibility cleanup
|
|
878
1133
|
for event in LEGACY_HOOK_TYPES:
|
|
@@ -885,7 +1140,11 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
885
1140
|
# Fail fast on malformed settings - Claude won't run with broken settings anyway
|
|
886
1141
|
if not isinstance(matcher, dict):
|
|
887
1142
|
raise ValueError(f"Malformed settings: matcher in {event} is not a dict: {type(matcher).__name__}")
|
|
888
|
-
|
|
1143
|
+
|
|
1144
|
+
# Validate hooks field if present
|
|
1145
|
+
if 'hooks' in matcher and not isinstance(matcher['hooks'], list):
|
|
1146
|
+
raise ValueError(f"Malformed settings: hooks in {event} matcher is not a list: {type(matcher['hooks']).__name__}")
|
|
1147
|
+
|
|
889
1148
|
# Work with a copy to avoid any potential reference issues
|
|
890
1149
|
matcher_copy = copy.deepcopy(matcher)
|
|
891
1150
|
|
|
@@ -894,7 +1153,7 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
894
1153
|
hook for hook in matcher_copy.get('hooks', [])
|
|
895
1154
|
if not any(
|
|
896
1155
|
pattern.search(hook.get('command', ''))
|
|
897
|
-
for pattern in
|
|
1156
|
+
for pattern in HCOM_HOOK_PATTERNS
|
|
898
1157
|
)
|
|
899
1158
|
]
|
|
900
1159
|
|
|
@@ -902,7 +1161,8 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
902
1161
|
if non_hcom_hooks:
|
|
903
1162
|
matcher_copy['hooks'] = non_hcom_hooks
|
|
904
1163
|
updated_matchers.append(matcher_copy)
|
|
905
|
-
elif
|
|
1164
|
+
elif 'hooks' not in matcher or matcher['hooks'] == []:
|
|
1165
|
+
# Preserve matchers that never had hooks (missing key or empty list only)
|
|
906
1166
|
updated_matchers.append(matcher_copy)
|
|
907
1167
|
|
|
908
1168
|
# Update or remove the event
|
|
@@ -919,7 +1179,7 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
919
1179
|
del settings['env']
|
|
920
1180
|
|
|
921
1181
|
|
|
922
|
-
def build_env_string(env_vars, format_type="bash"):
|
|
1182
|
+
def build_env_string(env_vars: dict[str, Any], format_type: str = "bash") -> str:
|
|
923
1183
|
"""Build environment variable string for bash shells"""
|
|
924
1184
|
if format_type == "bash_export":
|
|
925
1185
|
# Properly escape values for bash
|
|
@@ -936,12 +1196,12 @@ def format_error(message: str, suggestion: str | None = None) -> str:
|
|
|
936
1196
|
return base
|
|
937
1197
|
|
|
938
1198
|
|
|
939
|
-
def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
1199
|
+
def has_claude_arg(claude_args: list[str] | None, arg_names: list[str], arg_prefixes: tuple[str, ...]) -> bool:
|
|
940
1200
|
"""Check if argument already exists in claude_args"""
|
|
941
|
-
return claude_args and any(
|
|
1201
|
+
return bool(claude_args and any(
|
|
942
1202
|
arg in arg_names or arg.startswith(arg_prefixes)
|
|
943
1203
|
for arg in claude_args
|
|
944
|
-
)
|
|
1204
|
+
))
|
|
945
1205
|
|
|
946
1206
|
def build_claude_command(agent_content: str | None = None, claude_args: list[str] | None = None, initial_prompt: str = "Say hi in chat", model: str | None = None, tools: str | None = None) -> tuple[str, str | None]:
|
|
947
1207
|
"""Build Claude command with proper argument handling
|
|
@@ -982,21 +1242,19 @@ def build_claude_command(agent_content: str | None = None, claude_args: list[str
|
|
|
982
1242
|
|
|
983
1243
|
cmd_parts.append(flag)
|
|
984
1244
|
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
cmd_parts.append(shlex.quote(initial_prompt))
|
|
991
|
-
|
|
1245
|
+
|
|
1246
|
+
# Add initial prompt if non-empty
|
|
1247
|
+
if initial_prompt:
|
|
1248
|
+
cmd_parts.append(shlex.quote(initial_prompt))
|
|
1249
|
+
|
|
992
1250
|
return ' '.join(cmd_parts), temp_file_path
|
|
993
1251
|
|
|
994
|
-
def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
1252
|
+
def create_bash_script(script_file: str, env: dict[str, Any], cwd: str | None, command_str: str, background: bool = False) -> None:
|
|
995
1253
|
"""Create a bash script for terminal launch
|
|
996
1254
|
Scripts provide uniform execution across all platforms/terminals.
|
|
997
1255
|
Cleanup behavior:
|
|
998
1256
|
- Normal scripts: append 'rm -f' command for self-deletion
|
|
999
|
-
- Background scripts: persist until
|
|
1257
|
+
- Background scripts: persist until `hcom reset logs` cleanup (24 hours)
|
|
1000
1258
|
- Agent scripts: treated like background (contain 'hcom_agent_')
|
|
1001
1259
|
"""
|
|
1002
1260
|
try:
|
|
@@ -1064,11 +1322,10 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
|
1064
1322
|
if platform.system() != 'Windows':
|
|
1065
1323
|
os.chmod(script_file, 0o755)
|
|
1066
1324
|
|
|
1067
|
-
def find_bash_on_windows():
|
|
1325
|
+
def find_bash_on_windows() -> str | None:
|
|
1068
1326
|
"""Find Git Bash on Windows, avoiding WSL's bash launcher"""
|
|
1069
1327
|
# Build prioritized list of bash candidates
|
|
1070
1328
|
candidates = []
|
|
1071
|
-
|
|
1072
1329
|
# 1. Common Git Bash locations (highest priority)
|
|
1073
1330
|
for base in [os.environ.get('PROGRAMFILES', r'C:\Program Files'),
|
|
1074
1331
|
os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
|
|
@@ -1077,7 +1334,6 @@ def find_bash_on_windows():
|
|
|
1077
1334
|
str(Path(base) / 'Git' / 'usr' / 'bin' / 'bash.exe'), # usr/bin is more common
|
|
1078
1335
|
str(Path(base) / 'Git' / 'bin' / 'bash.exe')
|
|
1079
1336
|
])
|
|
1080
|
-
|
|
1081
1337
|
# 2. Portable Git installation
|
|
1082
1338
|
if local_appdata := os.environ.get('LOCALAPPDATA', ''):
|
|
1083
1339
|
git_portable = Path(local_appdata) / 'Programs' / 'Git'
|
|
@@ -1085,11 +1341,9 @@ def find_bash_on_windows():
|
|
|
1085
1341
|
str(git_portable / 'usr' / 'bin' / 'bash.exe'),
|
|
1086
1342
|
str(git_portable / 'bin' / 'bash.exe')
|
|
1087
1343
|
])
|
|
1088
|
-
|
|
1089
1344
|
# 3. PATH bash (if not WSL's launcher)
|
|
1090
1345
|
if (path_bash := shutil.which('bash')) and not path_bash.lower().endswith(r'system32\bash.exe'):
|
|
1091
1346
|
candidates.append(path_bash)
|
|
1092
|
-
|
|
1093
1347
|
# 4. Hardcoded fallbacks (last resort)
|
|
1094
1348
|
candidates.extend([
|
|
1095
1349
|
r'C:\Program Files\Git\usr\bin\bash.exe',
|
|
@@ -1097,7 +1351,6 @@ def find_bash_on_windows():
|
|
|
1097
1351
|
r'C:\Program Files (x86)\Git\usr\bin\bash.exe',
|
|
1098
1352
|
r'C:\Program Files (x86)\Git\bin\bash.exe'
|
|
1099
1353
|
])
|
|
1100
|
-
|
|
1101
1354
|
# Find first existing bash
|
|
1102
1355
|
for bash in candidates:
|
|
1103
1356
|
if bash and Path(bash).exists():
|
|
@@ -1106,11 +1359,11 @@ def find_bash_on_windows():
|
|
|
1106
1359
|
return None
|
|
1107
1360
|
|
|
1108
1361
|
# New helper functions for platform-specific terminal launching
|
|
1109
|
-
def get_macos_terminal_argv():
|
|
1362
|
+
def get_macos_terminal_argv() -> list[str]:
|
|
1110
1363
|
"""Return macOS Terminal.app launch command as argv list."""
|
|
1111
1364
|
return ['osascript', '-e', 'tell app "Terminal" to do script "bash {script}"', '-e', 'tell app "Terminal" to activate']
|
|
1112
1365
|
|
|
1113
|
-
def get_windows_terminal_argv():
|
|
1366
|
+
def get_windows_terminal_argv() -> list[str]:
|
|
1114
1367
|
"""Return Windows terminal launcher as argv list."""
|
|
1115
1368
|
if not (bash_exe := find_bash_on_windows()):
|
|
1116
1369
|
raise Exception(format_error("Git Bash not found"))
|
|
@@ -1119,7 +1372,7 @@ def get_windows_terminal_argv():
|
|
|
1119
1372
|
return ['wt', bash_exe, '{script}']
|
|
1120
1373
|
return ['cmd', '/c', 'start', 'Claude Code', bash_exe, '{script}']
|
|
1121
1374
|
|
|
1122
|
-
def get_linux_terminal_argv():
|
|
1375
|
+
def get_linux_terminal_argv() -> list[str] | None:
|
|
1123
1376
|
"""Return first available Linux terminal as argv list."""
|
|
1124
1377
|
terminals = [
|
|
1125
1378
|
('gnome-terminal', ['gnome-terminal', '--', 'bash', '{script}']),
|
|
@@ -1138,7 +1391,7 @@ def get_linux_terminal_argv():
|
|
|
1138
1391
|
|
|
1139
1392
|
return None
|
|
1140
1393
|
|
|
1141
|
-
def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
|
|
1394
|
+
def windows_hidden_popen(argv: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None, stdout: Any = None) -> subprocess.Popen:
|
|
1142
1395
|
"""Create hidden Windows process without console window."""
|
|
1143
1396
|
if IS_WINDOWS:
|
|
1144
1397
|
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
|
@@ -1165,7 +1418,7 @@ PLATFORM_TERMINAL_GETTERS = {
|
|
|
1165
1418
|
'Linux': get_linux_terminal_argv,
|
|
1166
1419
|
}
|
|
1167
1420
|
|
|
1168
|
-
def _parse_terminal_command(template, script_file):
|
|
1421
|
+
def _parse_terminal_command(template: str, script_file: str) -> list[str]:
|
|
1169
1422
|
"""Parse terminal command template safely to prevent shell injection.
|
|
1170
1423
|
Parses the template FIRST, then replaces {script} placeholder in the
|
|
1171
1424
|
parsed tokens. This avoids shell injection and handles paths with spaces.
|
|
@@ -1203,7 +1456,7 @@ def _parse_terminal_command(template, script_file):
|
|
|
1203
1456
|
|
|
1204
1457
|
return replaced
|
|
1205
1458
|
|
|
1206
|
-
def launch_terminal(command, env, cwd=None, background=False):
|
|
1459
|
+
def launch_terminal(command: str, env: dict[str, str], cwd: str | None = None, background: bool = False) -> str | bool | None:
|
|
1207
1460
|
"""Launch terminal with command using unified script-first approach
|
|
1208
1461
|
Args:
|
|
1209
1462
|
command: Command string from build_claude_command
|
|
@@ -1265,9 +1518,9 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1265
1518
|
return str(log_file)
|
|
1266
1519
|
|
|
1267
1520
|
# 3) Terminal modes
|
|
1268
|
-
terminal_mode =
|
|
1521
|
+
terminal_mode = get_config().terminal
|
|
1269
1522
|
|
|
1270
|
-
if terminal_mode == '
|
|
1523
|
+
if terminal_mode == 'print':
|
|
1271
1524
|
# Print script path and contents
|
|
1272
1525
|
try:
|
|
1273
1526
|
with open(script_file, 'r', encoding='utf-8') as f:
|
|
@@ -1280,7 +1533,7 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1280
1533
|
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
1281
1534
|
return False
|
|
1282
1535
|
|
|
1283
|
-
if terminal_mode == '
|
|
1536
|
+
if terminal_mode == 'here':
|
|
1284
1537
|
print("Launching Claude in current terminal...")
|
|
1285
1538
|
if IS_WINDOWS:
|
|
1286
1539
|
bash_exe = find_bash_on_windows()
|
|
@@ -1292,10 +1545,11 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1292
1545
|
result = subprocess.run(['bash', script_file], env=env_vars, cwd=cwd)
|
|
1293
1546
|
return result.returncode == 0
|
|
1294
1547
|
|
|
1295
|
-
# 4) New window mode
|
|
1296
|
-
|
|
1548
|
+
# 4) New window or custom command mode
|
|
1549
|
+
# If terminal is not 'here' or 'print', it's either 'new' (platform default) or a custom command
|
|
1550
|
+
custom_cmd = None if terminal_mode == 'new' else terminal_mode
|
|
1297
1551
|
|
|
1298
|
-
if not custom_cmd: #
|
|
1552
|
+
if not custom_cmd: # Platform default 'new' mode
|
|
1299
1553
|
if is_termux():
|
|
1300
1554
|
# Keep Termux as special case
|
|
1301
1555
|
am_cmd = [
|
|
@@ -1361,18 +1615,30 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1361
1615
|
print(format_error(f"Failed to execute terminal command: {e}"), file=sys.stderr)
|
|
1362
1616
|
return False
|
|
1363
1617
|
|
|
1364
|
-
def setup_hooks():
|
|
1365
|
-
"""Set up Claude hooks in
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
settings_path = claude_dir / 'settings.local.json'
|
|
1618
|
+
def setup_hooks() -> bool:
|
|
1619
|
+
"""Set up Claude hooks globally in ~/.claude/settings.json"""
|
|
1620
|
+
|
|
1621
|
+
# TODO: Remove after v0.6.0 - cleanup legacy per-directory hooks
|
|
1370
1622
|
try:
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1623
|
+
positions = load_all_positions()
|
|
1624
|
+
if positions:
|
|
1625
|
+
directories = set()
|
|
1626
|
+
for instance_data in positions.values():
|
|
1627
|
+
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
1628
|
+
directories.add(instance_data['directory'])
|
|
1629
|
+
for directory in directories:
|
|
1630
|
+
if Path(directory).exists():
|
|
1631
|
+
cleanup_directory_hooks(Path(directory))
|
|
1632
|
+
except Exception:
|
|
1633
|
+
pass # Don't fail hook setup if cleanup fails
|
|
1634
|
+
|
|
1635
|
+
# Install to global user settings
|
|
1636
|
+
settings_path = get_claude_settings_path()
|
|
1637
|
+
settings_path.parent.mkdir(exist_ok=True)
|
|
1638
|
+
try:
|
|
1639
|
+
settings = load_settings_json(settings_path, default={})
|
|
1640
|
+
if settings is None:
|
|
1641
|
+
settings = {}
|
|
1376
1642
|
except (json.JSONDecodeError, PermissionError) as e:
|
|
1377
1643
|
raise Exception(format_error(f"Cannot read settings: {e}"))
|
|
1378
1644
|
|
|
@@ -1384,24 +1650,12 @@ def setup_hooks():
|
|
|
1384
1650
|
# Get the hook command template
|
|
1385
1651
|
hook_cmd_base, _ = get_hook_command()
|
|
1386
1652
|
|
|
1387
|
-
#
|
|
1388
|
-
# Format: (hook_type, matcher, command, timeout)
|
|
1653
|
+
# Build hook commands from HOOK_CONFIGS
|
|
1389
1654
|
hook_configs = [
|
|
1390
|
-
(
|
|
1391
|
-
|
|
1392
|
-
('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
|
|
1393
|
-
('Stop', '', f'{hook_cmd_base} poll', 86400), # 24hr timeout max; internal timeout 30min default via config
|
|
1394
|
-
('Notification', '', f'{hook_cmd_base} notify', None),
|
|
1395
|
-
('SessionEnd', '', f'{hook_cmd_base} sessionend', None),
|
|
1655
|
+
(hook_type, matcher, f'{hook_cmd_base} {cmd_suffix}', timeout)
|
|
1656
|
+
for hook_type, matcher, cmd_suffix, timeout in HOOK_CONFIGS
|
|
1396
1657
|
]
|
|
1397
1658
|
|
|
1398
|
-
# Validate hook_configs matches ACTIVE_HOOK_TYPES
|
|
1399
|
-
configured_types = [hook_type for hook_type, _, _, _ in hook_configs]
|
|
1400
|
-
if configured_types != ACTIVE_HOOK_TYPES:
|
|
1401
|
-
raise Exception(format_error(
|
|
1402
|
-
f"Hook configuration mismatch: {configured_types} != {ACTIVE_HOOK_TYPES}"
|
|
1403
|
-
))
|
|
1404
|
-
|
|
1405
1659
|
for hook_type, matcher, command, timeout in hook_configs:
|
|
1406
1660
|
if hook_type not in settings['hooks']:
|
|
1407
1661
|
settings['hooks'][hook_type] = []
|
|
@@ -1422,9 +1676,8 @@ def setup_hooks():
|
|
|
1422
1676
|
if 'env' not in settings:
|
|
1423
1677
|
settings['env'] = {}
|
|
1424
1678
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
settings['env']['HCOM'] = f'{python_path} {script_path}'
|
|
1679
|
+
# Set HCOM based on current execution context (uvx, hcom binary, or full path)
|
|
1680
|
+
settings['env']['HCOM'] = _build_hcom_env_value()
|
|
1428
1681
|
|
|
1429
1682
|
# Write settings atomically
|
|
1430
1683
|
try:
|
|
@@ -1438,37 +1691,32 @@ def setup_hooks():
|
|
|
1438
1691
|
|
|
1439
1692
|
return True
|
|
1440
1693
|
|
|
1441
|
-
def verify_hooks_installed(settings_path):
|
|
1694
|
+
def verify_hooks_installed(settings_path: Path) -> bool:
|
|
1442
1695
|
"""Verify that HCOM hooks were installed correctly with correct commands"""
|
|
1443
1696
|
try:
|
|
1444
|
-
settings =
|
|
1445
|
-
settings_path,
|
|
1446
|
-
lambda f: json.load(f),
|
|
1447
|
-
default=None
|
|
1448
|
-
)
|
|
1697
|
+
settings = load_settings_json(settings_path, default=None)
|
|
1449
1698
|
if not settings:
|
|
1450
1699
|
return False
|
|
1451
1700
|
|
|
1452
|
-
# Check all hook types have correct commands
|
|
1701
|
+
# Check all hook types have correct commands (exactly one HCOM hook per type)
|
|
1702
|
+
# Derive from HOOK_CONFIGS (single source of truth)
|
|
1453
1703
|
hooks = settings.get('hooks', {})
|
|
1454
|
-
for hook_type,
|
|
1704
|
+
for hook_type, _, cmd_suffix, _ in HOOK_CONFIGS:
|
|
1455
1705
|
hook_matchers = hooks.get(hook_type, [])
|
|
1456
1706
|
if not hook_matchers:
|
|
1457
1707
|
return False
|
|
1458
1708
|
|
|
1459
|
-
#
|
|
1460
|
-
|
|
1709
|
+
# Count HCOM hooks for this type
|
|
1710
|
+
hcom_hook_count = 0
|
|
1461
1711
|
for matcher in hook_matchers:
|
|
1462
1712
|
for hook in matcher.get('hooks', []):
|
|
1463
1713
|
command = hook.get('command', '')
|
|
1464
1714
|
# Check for HCOM and the correct subcommand
|
|
1465
|
-
if ('${HCOM}' in command or 'hcom' in command.lower()) and
|
|
1466
|
-
|
|
1467
|
-
break
|
|
1468
|
-
if found_correct_cmd:
|
|
1469
|
-
break
|
|
1715
|
+
if ('${HCOM}' in command or 'hcom' in command.lower()) and cmd_suffix in command:
|
|
1716
|
+
hcom_hook_count += 1
|
|
1470
1717
|
|
|
1471
|
-
|
|
1718
|
+
# Must have exactly one HCOM hook (not zero, not duplicates)
|
|
1719
|
+
if hcom_hook_count != 1:
|
|
1472
1720
|
return False
|
|
1473
1721
|
|
|
1474
1722
|
# Check that HCOM env var is set
|
|
@@ -1480,11 +1728,11 @@ def verify_hooks_installed(settings_path):
|
|
|
1480
1728
|
except Exception:
|
|
1481
1729
|
return False
|
|
1482
1730
|
|
|
1483
|
-
def is_interactive():
|
|
1731
|
+
def is_interactive() -> bool:
|
|
1484
1732
|
"""Check if running in interactive mode"""
|
|
1485
1733
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
1486
1734
|
|
|
1487
|
-
def get_archive_timestamp():
|
|
1735
|
+
def get_archive_timestamp() -> str:
|
|
1488
1736
|
"""Get timestamp for archive files"""
|
|
1489
1737
|
return datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
1490
1738
|
|
|
@@ -1602,17 +1850,24 @@ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
1602
1850
|
|
|
1603
1851
|
# Check timeout
|
|
1604
1852
|
age = now - last_status_time
|
|
1605
|
-
timeout = pos_data.get('wait_timeout',
|
|
1853
|
+
timeout = pos_data.get('wait_timeout', get_config().timeout)
|
|
1606
1854
|
if age > timeout:
|
|
1607
1855
|
return "inactive", "", "timeout"
|
|
1608
1856
|
|
|
1857
|
+
# Check Stop hook heartbeat for both blocked-generic and waiting-stale detection
|
|
1858
|
+
last_stop = pos_data.get('last_stop', 0)
|
|
1859
|
+
heartbeat_age = now - last_stop if last_stop else 999999
|
|
1860
|
+
|
|
1861
|
+
# Generic "Claude is waiting for your input" from Notification hook is meaningless
|
|
1862
|
+
# If Stop hook is actively polling (heartbeat < 2s), instance is actually idle
|
|
1863
|
+
if last_status == 'blocked' and last_context == "Claude is waiting for your input" and heartbeat_age < 2:
|
|
1864
|
+
last_status = 'waiting'
|
|
1865
|
+
display_status, desc_template = 'waiting', 'idle'
|
|
1866
|
+
|
|
1609
1867
|
# 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"
|
|
1868
|
+
if last_status == 'waiting' and heartbeat_age > 2:
|
|
1869
|
+
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1870
|
+
return "unknown", f"({format_age(heartbeat_age)}){status_suffix}", "stale"
|
|
1616
1871
|
|
|
1617
1872
|
# Format description with context if template has {}
|
|
1618
1873
|
if '{}' in desc_template and last_context:
|
|
@@ -1629,15 +1884,12 @@ def get_status_block(status_type: str) -> str:
|
|
|
1629
1884
|
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
1630
1885
|
return f"{text_color}{BOLD}{color} {symbol} {RESET}"
|
|
1631
1886
|
|
|
1632
|
-
def format_message_line(msg, truncate=False):
|
|
1887
|
+
def format_message_line(msg: dict[str, str], truncate: bool = False) -> str:
|
|
1633
1888
|
"""Format a message for display"""
|
|
1634
1889
|
time_obj = datetime.fromisoformat(msg['timestamp'])
|
|
1635
1890
|
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']
|
|
1891
|
+
|
|
1892
|
+
display_name = f"{SENDER_EMOJI} {msg['from']}" if msg['from'] == SENDER else msg['from']
|
|
1641
1893
|
|
|
1642
1894
|
if truncate:
|
|
1643
1895
|
sender = display_name[:10]
|
|
@@ -1646,7 +1898,7 @@ def format_message_line(msg, truncate=False):
|
|
|
1646
1898
|
else:
|
|
1647
1899
|
return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
|
|
1648
1900
|
|
|
1649
|
-
def show_recent_messages(messages, limit=None, truncate=False):
|
|
1901
|
+
def show_recent_messages(messages: list[dict[str, str]], limit: int | None = None, truncate: bool = False) -> None:
|
|
1650
1902
|
"""Show recent messages"""
|
|
1651
1903
|
if limit is None:
|
|
1652
1904
|
messages_to_show = messages
|
|
@@ -1658,14 +1910,14 @@ def show_recent_messages(messages, limit=None, truncate=False):
|
|
|
1658
1910
|
print(format_message_line(msg, truncate))
|
|
1659
1911
|
|
|
1660
1912
|
|
|
1661
|
-
def get_terminal_height():
|
|
1913
|
+
def get_terminal_height() -> int:
|
|
1662
1914
|
"""Get current terminal height"""
|
|
1663
1915
|
try:
|
|
1664
1916
|
return shutil.get_terminal_size().lines
|
|
1665
1917
|
except (AttributeError, OSError):
|
|
1666
1918
|
return 24
|
|
1667
1919
|
|
|
1668
|
-
def show_recent_activity_alt_screen(limit=None):
|
|
1920
|
+
def show_recent_activity_alt_screen(limit: int | None = None) -> None:
|
|
1669
1921
|
"""Show recent messages in alt screen format with dynamic height"""
|
|
1670
1922
|
if limit is None:
|
|
1671
1923
|
# Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
|
|
@@ -1677,7 +1929,7 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1677
1929
|
messages = parse_log_messages(log_file).messages
|
|
1678
1930
|
show_recent_messages(messages, limit, truncate=True)
|
|
1679
1931
|
|
|
1680
|
-
def should_show_in_watch(d):
|
|
1932
|
+
def should_show_in_watch(d: dict[str, Any]) -> bool:
|
|
1681
1933
|
"""Show only enabled instances by default"""
|
|
1682
1934
|
# Hide disabled instances
|
|
1683
1935
|
if not d.get('enabled', False):
|
|
@@ -1690,7 +1942,7 @@ def should_show_in_watch(d):
|
|
|
1690
1942
|
# Show all other instances (including 'closed' during transition)
|
|
1691
1943
|
return True
|
|
1692
1944
|
|
|
1693
|
-
def show_instances_by_directory():
|
|
1945
|
+
def show_instances_by_directory() -> None:
|
|
1694
1946
|
"""Show instances organized by their working directories"""
|
|
1695
1947
|
positions = load_all_positions()
|
|
1696
1948
|
if not positions:
|
|
@@ -1747,7 +1999,7 @@ def alt_screen_detailed_status_and_input() -> str:
|
|
|
1747
1999
|
|
|
1748
2000
|
return message
|
|
1749
2001
|
|
|
1750
|
-
def get_status_summary():
|
|
2002
|
+
def get_status_summary() -> str:
|
|
1751
2003
|
"""Get a one-line summary of all instance statuses"""
|
|
1752
2004
|
positions = load_all_positions()
|
|
1753
2005
|
if not positions:
|
|
@@ -1780,18 +2032,18 @@ def get_status_summary():
|
|
|
1780
2032
|
else:
|
|
1781
2033
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1782
2034
|
|
|
1783
|
-
def update_status(s):
|
|
2035
|
+
def update_status(s: str) -> None:
|
|
1784
2036
|
"""Update status line in place"""
|
|
1785
2037
|
sys.stdout.write("\r\033[K" + s)
|
|
1786
2038
|
sys.stdout.flush()
|
|
1787
2039
|
|
|
1788
|
-
def log_line_with_status(message, status):
|
|
2040
|
+
def log_line_with_status(message: str, status: str) -> None:
|
|
1789
2041
|
"""Print message and immediately restore status"""
|
|
1790
2042
|
sys.stdout.write("\r\033[K" + message + "\n")
|
|
1791
2043
|
sys.stdout.write("\033[K" + status)
|
|
1792
2044
|
sys.stdout.flush()
|
|
1793
2045
|
|
|
1794
|
-
def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
2046
|
+
def initialize_instance_in_position_file(instance_name: str, session_id: str | None = None) -> bool:
|
|
1795
2047
|
"""Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
|
|
1796
2048
|
try:
|
|
1797
2049
|
data = load_instance_position(instance_name)
|
|
@@ -1799,15 +2051,23 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1799
2051
|
# Determine default enabled state: True for hcom-launched, False for vanilla
|
|
1800
2052
|
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
1801
2053
|
|
|
2054
|
+
# Determine starting position: skip history or read from beginning (or last max_msgs num)
|
|
2055
|
+
initial_pos = 0
|
|
2056
|
+
if SKIP_HISTORY:
|
|
2057
|
+
log_file = hcom_path(LOG_FILE)
|
|
2058
|
+
if log_file.exists():
|
|
2059
|
+
initial_pos = log_file.stat().st_size
|
|
2060
|
+
|
|
1802
2061
|
defaults = {
|
|
1803
|
-
"pos":
|
|
2062
|
+
"pos": initial_pos,
|
|
1804
2063
|
"enabled": is_hcom_launched,
|
|
1805
2064
|
"directory": str(Path.cwd()),
|
|
1806
2065
|
"last_stop": 0,
|
|
1807
2066
|
"session_id": session_id or "",
|
|
1808
2067
|
"transcript_path": "",
|
|
1809
2068
|
"notification_message": "",
|
|
1810
|
-
"alias_announced": False
|
|
2069
|
+
"alias_announced": False,
|
|
2070
|
+
"tag": None
|
|
1811
2071
|
}
|
|
1812
2072
|
|
|
1813
2073
|
# Add missing fields (preserve existing)
|
|
@@ -1818,7 +2078,7 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1818
2078
|
except Exception:
|
|
1819
2079
|
return False
|
|
1820
2080
|
|
|
1821
|
-
def update_instance_position(instance_name, update_fields):
|
|
2081
|
+
def update_instance_position(instance_name: str, update_fields: dict[str, Any]) -> None:
|
|
1822
2082
|
"""Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
|
|
1823
2083
|
try:
|
|
1824
2084
|
data = load_instance_position(instance_name)
|
|
@@ -1837,7 +2097,7 @@ def update_instance_position(instance_name, update_fields):
|
|
|
1837
2097
|
else:
|
|
1838
2098
|
raise
|
|
1839
2099
|
|
|
1840
|
-
def enable_instance(instance_name):
|
|
2100
|
+
def enable_instance(instance_name: str) -> None:
|
|
1841
2101
|
"""Enable instance - clears all stop flags and enables Stop hook polling"""
|
|
1842
2102
|
update_instance_position(instance_name, {
|
|
1843
2103
|
'enabled': True,
|
|
@@ -1846,14 +2106,13 @@ def enable_instance(instance_name):
|
|
|
1846
2106
|
})
|
|
1847
2107
|
set_status(instance_name, 'started')
|
|
1848
2108
|
|
|
1849
|
-
def disable_instance(instance_name, force=False):
|
|
2109
|
+
def disable_instance(instance_name: str, force: bool = False) -> None:
|
|
1850
2110
|
"""Disable instance - stops Stop hook polling"""
|
|
1851
2111
|
updates = {
|
|
1852
2112
|
'enabled': False
|
|
1853
2113
|
}
|
|
1854
2114
|
if force:
|
|
1855
2115
|
updates['force_closed'] = True
|
|
1856
|
-
|
|
1857
2116
|
update_instance_position(instance_name, updates)
|
|
1858
2117
|
set_status(instance_name, 'force_stopped' if force else 'stopped')
|
|
1859
2118
|
|
|
@@ -1864,82 +2123,14 @@ def set_status(instance_name: str, status: str, context: str = ''):
|
|
|
1864
2123
|
'last_status_time': int(time.time()),
|
|
1865
2124
|
'last_status_context': context
|
|
1866
2125
|
})
|
|
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
|
-
|
|
2126
|
+
log_hook_error('set_status', f'Setting status to {status} with context {context} for {instance_name}')
|
|
1936
2127
|
|
|
1937
2128
|
# ==================== Command Functions ====================
|
|
1938
2129
|
|
|
1939
|
-
def show_main_screen_header():
|
|
2130
|
+
def show_main_screen_header() -> list[dict[str, str]]:
|
|
1940
2131
|
"""Show header for main screen"""
|
|
1941
2132
|
sys.stdout.write("\033[2J\033[H")
|
|
1942
|
-
|
|
2133
|
+
|
|
1943
2134
|
log_file = hcom_path(LOG_FILE)
|
|
1944
2135
|
all_messages = []
|
|
1945
2136
|
if log_file.exists():
|
|
@@ -1950,279 +2141,64 @@ def show_main_screen_header():
|
|
|
1950
2141
|
|
|
1951
2142
|
return all_messages
|
|
1952
2143
|
|
|
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():
|
|
2144
|
+
def cmd_help() -> int:
|
|
2130
2145
|
"""Show help text"""
|
|
2131
2146
|
print(HELP_TEXT)
|
|
2132
|
-
|
|
2133
|
-
# Additional help for AI assistants
|
|
2134
|
-
if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
|
|
2135
|
-
print("""
|
|
2136
|
-
|
|
2137
|
-
=== ADDITIONAL INFO ===
|
|
2138
|
-
|
|
2139
|
-
CONCEPT: HCOM launches Claude Code instances in new terminal windows.
|
|
2140
|
-
They communicate with each other via a shared conversation.
|
|
2141
|
-
You communicate with them via hcom commands.
|
|
2142
|
-
|
|
2143
|
-
KEY UNDERSTANDING:
|
|
2144
|
-
• Single conversation - All instances share ~/.hcom/hcom.log
|
|
2145
|
-
• Messaging - CLI and instances send with hcom send "message"
|
|
2146
|
-
• Instances receive messages via hooks automatically
|
|
2147
|
-
• hcom open is directory-specific - always cd to project directory first
|
|
2148
|
-
• Named agents are custom system prompt files created by users/claude code beforehand.
|
|
2149
|
-
• Named agents load from .claude/agents/<name>.md - if they have been created
|
|
2150
|
-
• hcom watch --wait outputs last 5 seconds of messages, waits for the next message, prints it, and exits.
|
|
2151
|
-
|
|
2152
|
-
LAUNCH PATTERNS:
|
|
2153
|
-
hcom open 2 # 2 generic instances
|
|
2154
|
-
hcom open -a reviewer # 1 reviewer instance (agent file must already exist)
|
|
2155
|
-
hcom open 3 -a reviewer # 3 reviewer instances
|
|
2156
|
-
hcom open -a reviewer -a tester # 1 reviewer + 1 tester
|
|
2157
|
-
hcom open -t api 2 # Team naming: api-hova7, api-kolec
|
|
2158
|
-
hcom open -- --model sonnet # Pass `claude` CLI flags after --
|
|
2159
|
-
hcom open -p # Detached background (stop with: hcom stop <alias>)
|
|
2160
|
-
hcom open -- --resume <sessionid> # Resume specific session
|
|
2161
|
-
HCOM_INITIAL_PROMPT="task" hcom open # Set initial prompt for instance
|
|
2162
|
-
|
|
2163
|
-
@MENTION TARGETING:
|
|
2164
|
-
hcom send "message" # Broadcasts to everyone
|
|
2165
|
-
hcom send "@api fix this" # Targets all api-* instances (api-hova7, api-kolec)
|
|
2166
|
-
hcom send "@hova7 status?" # Targets specific instance
|
|
2167
|
-
(Unmatched @mentions broadcast to everyone)
|
|
2168
|
-
|
|
2169
|
-
STATUS INDICATORS:
|
|
2170
|
-
• ▶ active - processing/executing • ▷ delivered - instance just received a message
|
|
2171
|
-
• ◉ idle - waiting for new messages • ■ blocked - permission request (needs user approval)
|
|
2172
|
-
• ○ inactive - timed out, disconnected, etc • ○ unknown
|
|
2173
|
-
|
|
2174
|
-
CONFIG:
|
|
2175
|
-
Config file (persistent): ~/.hcom/config.json
|
|
2176
|
-
|
|
2177
|
-
Key settings (full list in config.json):
|
|
2178
|
-
terminal_mode: "new_window" (default) | "same_terminal" | "show_commands"
|
|
2179
|
-
initial_prompt: "Say hi in chat", first_use_text: "Essential messages only..."
|
|
2180
|
-
instance_hints: "text", cli_hints: "text" # Extra info for instances/CLI
|
|
2181
|
-
env_overrides: "custom environment variables for instances"
|
|
2182
|
-
|
|
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
|
-
|
|
2187
|
-
EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
|
|
2188
|
-
with 'hcom watch --status'. Instances respond automatically in shared chat.
|
|
2189
|
-
|
|
2190
|
-
Run 'claude --help' to see all claude code CLI flags.""")
|
|
2191
|
-
|
|
2192
|
-
show_cli_hints(to_stderr=False)
|
|
2193
|
-
else:
|
|
2194
|
-
if not IS_WINDOWS:
|
|
2195
|
-
print("\nFor additional info & examples: hcom --help | cat")
|
|
2196
|
-
|
|
2197
2147
|
return 0
|
|
2198
2148
|
|
|
2199
|
-
def
|
|
2200
|
-
"""Launch Claude instances
|
|
2149
|
+
def cmd_launch(argv: list[str]) -> int:
|
|
2150
|
+
"""Launch Claude instances: hcom [N] [claude] [args]"""
|
|
2201
2151
|
try:
|
|
2152
|
+
# Parse arguments: hcom [N] [claude] [args]
|
|
2153
|
+
count = 1
|
|
2154
|
+
forwarded = []
|
|
2155
|
+
|
|
2156
|
+
# Extract count if first arg is digit
|
|
2157
|
+
if argv and argv[0].isdigit():
|
|
2158
|
+
count = int(argv[0])
|
|
2159
|
+
if count <= 0:
|
|
2160
|
+
raise CLIError('Count must be positive.')
|
|
2161
|
+
if count > 100:
|
|
2162
|
+
raise CLIError('Too many instances requested (max 100).')
|
|
2163
|
+
argv = argv[1:]
|
|
2164
|
+
|
|
2165
|
+
# Skip 'claude' keyword if present
|
|
2166
|
+
if argv and argv[0] == 'claude':
|
|
2167
|
+
argv = argv[1:]
|
|
2168
|
+
|
|
2169
|
+
# Forward all remaining args to claude CLI
|
|
2170
|
+
forwarded = argv
|
|
2171
|
+
|
|
2172
|
+
# Get tag from config
|
|
2173
|
+
tag = get_config().tag
|
|
2174
|
+
if tag and '|' in tag:
|
|
2175
|
+
raise CLIError('Tag cannot contain "|" characters.')
|
|
2176
|
+
|
|
2177
|
+
# Get agents from config (comma-separated)
|
|
2178
|
+
agent_env = get_config().agent
|
|
2179
|
+
agents = [a.strip() for a in agent_env.split(',') if a.strip()] if agent_env else ['']
|
|
2180
|
+
|
|
2181
|
+
# Detect background mode from -p/--print flags in forwarded args
|
|
2182
|
+
background = '-p' in forwarded or '--print' in forwarded
|
|
2183
|
+
|
|
2202
2184
|
# Add -p flag and stream-json output for background mode if not already present
|
|
2203
|
-
claude_args =
|
|
2204
|
-
if
|
|
2185
|
+
claude_args = forwarded
|
|
2186
|
+
if background and '-p' not in claude_args and '--print' not in claude_args:
|
|
2205
2187
|
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
2206
2188
|
|
|
2207
|
-
terminal_mode =
|
|
2189
|
+
terminal_mode = get_config().terminal
|
|
2208
2190
|
|
|
2209
2191
|
# Calculate total instances to launch
|
|
2210
|
-
total_instances =
|
|
2192
|
+
total_instances = count * len(agents)
|
|
2211
2193
|
|
|
2212
|
-
# Fail fast for
|
|
2213
|
-
if terminal_mode == '
|
|
2194
|
+
# Fail fast for here mode with multiple instances
|
|
2195
|
+
if terminal_mode == 'here' and total_instances > 1:
|
|
2214
2196
|
print(format_error(
|
|
2215
|
-
f"
|
|
2216
|
-
"Use 'hcom
|
|
2197
|
+
f"'here' mode cannot launch {total_instances} instances (it's one terminal window)",
|
|
2198
|
+
"Use 'hcom 1' for one generic instance"
|
|
2217
2199
|
), file=sys.stderr)
|
|
2218
2200
|
return 1
|
|
2219
2201
|
|
|
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
2202
|
log_file = hcom_path(LOG_FILE)
|
|
2227
2203
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2228
2204
|
|
|
@@ -2232,21 +2208,16 @@ def cmd_open(command: OpenCommand):
|
|
|
2232
2208
|
# Build environment variables for Claude instances
|
|
2233
2209
|
base_env = build_claude_env()
|
|
2234
2210
|
|
|
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
|
|
2211
|
+
# Add tag-specific hints if provided
|
|
2212
|
+
if tag:
|
|
2213
|
+
base_env['HCOM_TAG'] = tag
|
|
2243
2214
|
|
|
2244
2215
|
launched = 0
|
|
2245
|
-
initial_prompt =
|
|
2216
|
+
initial_prompt = get_config().prompt
|
|
2246
2217
|
|
|
2247
2218
|
# Launch count instances of each agent
|
|
2248
|
-
for agent in
|
|
2249
|
-
for _ in range(
|
|
2219
|
+
for agent in agents:
|
|
2220
|
+
for _ in range(count):
|
|
2250
2221
|
instance_type = agent
|
|
2251
2222
|
instance_env = base_env.copy()
|
|
2252
2223
|
|
|
@@ -2254,14 +2225,14 @@ def cmd_open(command: OpenCommand):
|
|
|
2254
2225
|
instance_env['HCOM_LAUNCHED'] = '1'
|
|
2255
2226
|
|
|
2256
2227
|
# Mark background instances via environment with log filename
|
|
2257
|
-
if
|
|
2228
|
+
if background:
|
|
2258
2229
|
# Generate unique log filename
|
|
2259
2230
|
log_filename = f'background_{int(time.time())}_{random.randint(1000, 9999)}.log'
|
|
2260
2231
|
instance_env['HCOM_BACKGROUND'] = log_filename
|
|
2261
2232
|
|
|
2262
2233
|
# Build claude command
|
|
2263
|
-
if instance_type
|
|
2264
|
-
#
|
|
2234
|
+
if not instance_type:
|
|
2235
|
+
# No agent - no agent content
|
|
2265
2236
|
claude_cmd, _ = build_claude_command(
|
|
2266
2237
|
agent_content=None,
|
|
2267
2238
|
claude_args=claude_args,
|
|
@@ -2292,7 +2263,7 @@ def cmd_open(command: OpenCommand):
|
|
|
2292
2263
|
continue
|
|
2293
2264
|
|
|
2294
2265
|
try:
|
|
2295
|
-
if
|
|
2266
|
+
if background:
|
|
2296
2267
|
log_file = launch_terminal(claude_cmd, instance_env, cwd=os.getcwd(), background=True)
|
|
2297
2268
|
if log_file:
|
|
2298
2269
|
print(f"Background instance launched, log: {log_file}")
|
|
@@ -2316,37 +2287,31 @@ def cmd_open(command: OpenCommand):
|
|
|
2316
2287
|
else:
|
|
2317
2288
|
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
2318
2289
|
|
|
2319
|
-
# Auto-launch watch dashboard if
|
|
2320
|
-
terminal_mode =
|
|
2321
|
-
auto_watch = get_config_value('auto_watch', True)
|
|
2290
|
+
# Auto-launch watch dashboard if in new window mode (new or custom) and all instances launched successfully
|
|
2291
|
+
terminal_mode = get_config().terminal
|
|
2322
2292
|
|
|
2323
|
-
# Only auto-watch if ALL instances launched successfully
|
|
2324
|
-
if terminal_mode
|
|
2293
|
+
# Only auto-watch if ALL instances launched successfully and launches windows (not 'here' or 'print')
|
|
2294
|
+
if terminal_mode not in ('here', 'print') and failed == 0 and is_interactive():
|
|
2325
2295
|
# Show tips first if needed
|
|
2326
|
-
if
|
|
2327
|
-
print(f"\n • Send to {
|
|
2296
|
+
if tag:
|
|
2297
|
+
print(f"\n • Send to {tag} team: hcom send '@{tag} message'")
|
|
2328
2298
|
|
|
2329
2299
|
# Clear transition message
|
|
2330
2300
|
print("\nOpening hcom watch...")
|
|
2331
2301
|
time.sleep(2) # Brief pause so user sees the message
|
|
2332
2302
|
|
|
2333
2303
|
# Launch interactive watch dashboard in current terminal
|
|
2334
|
-
|
|
2335
|
-
return cmd_watch(watch_cmd)
|
|
2304
|
+
return cmd_watch([]) # Empty argv = interactive mode
|
|
2336
2305
|
else:
|
|
2337
2306
|
tips = [
|
|
2338
2307
|
"Run 'hcom watch' to view/send in conversation dashboard",
|
|
2339
2308
|
]
|
|
2340
|
-
if
|
|
2341
|
-
tips.append(f"Send to {
|
|
2309
|
+
if tag:
|
|
2310
|
+
tips.append(f"Send to {tag} team: hcom send '@{tag} message'")
|
|
2342
2311
|
|
|
2343
2312
|
if tips:
|
|
2344
2313
|
print("\n" + "\n".join(f" • {tip}" for tip in tips) + "\n")
|
|
2345
2314
|
|
|
2346
|
-
# Show cli_hints if configured (non-interactive mode)
|
|
2347
|
-
if not is_interactive():
|
|
2348
|
-
show_cli_hints(to_stderr=False)
|
|
2349
|
-
|
|
2350
2315
|
return 0
|
|
2351
2316
|
|
|
2352
2317
|
except ValueError as e:
|
|
@@ -2356,20 +2321,45 @@ def cmd_open(command: OpenCommand):
|
|
|
2356
2321
|
print(str(e), file=sys.stderr)
|
|
2357
2322
|
return 1
|
|
2358
2323
|
|
|
2359
|
-
def cmd_watch(
|
|
2360
|
-
"""View conversation dashboard"""
|
|
2324
|
+
def cmd_watch(argv: list[str]) -> int:
|
|
2325
|
+
"""View conversation dashboard: hcom watch [--logs|--status|--wait [SEC]]"""
|
|
2326
|
+
# Extract launch flag for external terminals (used by claude code bootstrap)
|
|
2327
|
+
cleaned_args: list[str] = []
|
|
2328
|
+
for arg in argv:
|
|
2329
|
+
if arg == '--launch':
|
|
2330
|
+
watch_cmd = f"{build_hcom_command()} watch"
|
|
2331
|
+
result = launch_terminal(watch_cmd, build_claude_env(), cwd=os.getcwd())
|
|
2332
|
+
return 0 if result else 1
|
|
2333
|
+
else:
|
|
2334
|
+
cleaned_args.append(arg)
|
|
2335
|
+
argv = cleaned_args
|
|
2336
|
+
|
|
2337
|
+
# Parse arguments
|
|
2338
|
+
show_logs = '--logs' in argv
|
|
2339
|
+
show_status = '--status' in argv
|
|
2340
|
+
wait_timeout = None
|
|
2341
|
+
|
|
2342
|
+
# Check for --wait flag
|
|
2343
|
+
if '--wait' in argv:
|
|
2344
|
+
idx = argv.index('--wait')
|
|
2345
|
+
if idx + 1 < len(argv):
|
|
2346
|
+
try:
|
|
2347
|
+
wait_timeout = int(argv[idx + 1])
|
|
2348
|
+
if wait_timeout < 0:
|
|
2349
|
+
raise CLIError('--wait expects a non-negative number of seconds.')
|
|
2350
|
+
except ValueError:
|
|
2351
|
+
wait_timeout = 60 # Default for non-numeric values
|
|
2352
|
+
else:
|
|
2353
|
+
wait_timeout = 60 # Default timeout
|
|
2354
|
+
show_logs = True # --wait implies logs mode
|
|
2355
|
+
|
|
2361
2356
|
log_file = hcom_path(LOG_FILE)
|
|
2362
2357
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2363
2358
|
|
|
2364
2359
|
if not log_file.exists() and not instances_dir.exists():
|
|
2365
|
-
print(format_error("No conversation log found", "Run 'hcom
|
|
2360
|
+
print(format_error("No conversation log found", "Run 'hcom' first"), file=sys.stderr)
|
|
2366
2361
|
return 1
|
|
2367
2362
|
|
|
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
2363
|
# Non-interactive mode (no TTY or flags specified)
|
|
2374
2364
|
if not is_interactive() or show_logs or show_status:
|
|
2375
2365
|
if show_logs:
|
|
@@ -2381,14 +2371,16 @@ def cmd_watch(command: WatchCommand):
|
|
|
2381
2371
|
last_pos = 0
|
|
2382
2372
|
messages = []
|
|
2383
2373
|
|
|
2384
|
-
# If --wait, show
|
|
2374
|
+
# If --wait, show recent messages (max of: last 3 messages OR all messages in last 5 seconds)
|
|
2385
2375
|
if wait_timeout is not None:
|
|
2386
2376
|
cutoff = datetime.now() - timedelta(seconds=5)
|
|
2387
|
-
|
|
2388
|
-
|
|
2377
|
+
recent_by_time = [m for m in messages if datetime.fromisoformat(m['timestamp']) > cutoff]
|
|
2378
|
+
last_three = messages[-3:] if len(messages) >= 3 else messages
|
|
2379
|
+
# Show whichever is larger: recent by time or last 3
|
|
2380
|
+
recent_messages = recent_by_time if len(recent_by_time) > len(last_three) else last_three
|
|
2389
2381
|
# Status to stderr, data to stdout
|
|
2390
2382
|
if recent_messages:
|
|
2391
|
-
print(f'---Showing
|
|
2383
|
+
print(f'---Showing recent messages---', file=sys.stderr)
|
|
2392
2384
|
for msg in recent_messages:
|
|
2393
2385
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2394
2386
|
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
@@ -2426,7 +2418,6 @@ def cmd_watch(command: WatchCommand):
|
|
|
2426
2418
|
else:
|
|
2427
2419
|
print("No messages yet", file=sys.stderr)
|
|
2428
2420
|
|
|
2429
|
-
show_cli_hints()
|
|
2430
2421
|
|
|
2431
2422
|
elif show_status:
|
|
2432
2423
|
# Build JSON output
|
|
@@ -2467,17 +2458,14 @@ def cmd_watch(command: WatchCommand):
|
|
|
2467
2458
|
}
|
|
2468
2459
|
|
|
2469
2460
|
print(json.dumps(output, indent=2))
|
|
2470
|
-
show_cli_hints()
|
|
2471
2461
|
else:
|
|
2472
2462
|
print("No TTY - Automation usage:", file=sys.stderr)
|
|
2473
|
-
print(" hcom send 'message' Send message to chat", file=sys.stderr)
|
|
2474
2463
|
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
2475
2464
|
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
2476
2465
|
print(" hcom watch --wait Wait for new messages", file=sys.stderr)
|
|
2466
|
+
print(" hcom watch --launch Launch interactive dashboard in new terminal", file=sys.stderr)
|
|
2477
2467
|
print(" Full information: hcom --help")
|
|
2478
2468
|
|
|
2479
|
-
show_cli_hints()
|
|
2480
|
-
|
|
2481
2469
|
return 0
|
|
2482
2470
|
|
|
2483
2471
|
# Interactive dashboard mode
|
|
@@ -2552,9 +2540,9 @@ def cmd_watch(command: WatchCommand):
|
|
|
2552
2540
|
last_pos = log_file.stat().st_size
|
|
2553
2541
|
|
|
2554
2542
|
if message and message.strip():
|
|
2555
|
-
|
|
2543
|
+
send_cli(message.strip(), quiet=True)
|
|
2556
2544
|
print(f"{FG_GREEN}✓ Sent{RESET}")
|
|
2557
|
-
|
|
2545
|
+
|
|
2558
2546
|
print()
|
|
2559
2547
|
|
|
2560
2548
|
current_status = get_status_summary()
|
|
@@ -2568,7 +2556,7 @@ def cmd_watch(command: WatchCommand):
|
|
|
2568
2556
|
|
|
2569
2557
|
return 0
|
|
2570
2558
|
|
|
2571
|
-
def
|
|
2559
|
+
def clear() -> int:
|
|
2572
2560
|
"""Clear and archive conversation"""
|
|
2573
2561
|
log_file = hcom_path(LOG_FILE)
|
|
2574
2562
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
@@ -2638,7 +2626,26 @@ def cmd_clear():
|
|
|
2638
2626
|
print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
|
|
2639
2627
|
return 1
|
|
2640
2628
|
|
|
2641
|
-
def
|
|
2629
|
+
def remove_global_hooks() -> bool:
|
|
2630
|
+
"""Remove HCOM hooks from ~/.claude/settings.json
|
|
2631
|
+
Returns True on success, False on failure."""
|
|
2632
|
+
settings_path = get_claude_settings_path()
|
|
2633
|
+
|
|
2634
|
+
if not settings_path.exists():
|
|
2635
|
+
return True # No settings = no hooks to remove
|
|
2636
|
+
|
|
2637
|
+
try:
|
|
2638
|
+
settings = load_settings_json(settings_path, default=None)
|
|
2639
|
+
if not settings:
|
|
2640
|
+
return False
|
|
2641
|
+
|
|
2642
|
+
_remove_hcom_hooks_from_settings(settings)
|
|
2643
|
+
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
2644
|
+
return True
|
|
2645
|
+
except Exception:
|
|
2646
|
+
return False
|
|
2647
|
+
|
|
2648
|
+
def cleanup_directory_hooks(directory: Path | str) -> tuple[int, str]:
|
|
2642
2649
|
"""Remove hcom hooks from a specific directory
|
|
2643
2650
|
Returns tuple: (exit_code, message)
|
|
2644
2651
|
exit_code: 0 for success, 1 for error
|
|
@@ -2651,11 +2658,7 @@ def cleanup_directory_hooks(directory):
|
|
|
2651
2658
|
|
|
2652
2659
|
try:
|
|
2653
2660
|
# Load existing settings
|
|
2654
|
-
settings =
|
|
2655
|
-
settings_path,
|
|
2656
|
-
lambda f: json.load(f),
|
|
2657
|
-
default=None
|
|
2658
|
-
)
|
|
2661
|
+
settings = load_settings_json(settings_path, default=None)
|
|
2659
2662
|
if not settings:
|
|
2660
2663
|
return 1, "Cannot read Claude settings"
|
|
2661
2664
|
|
|
@@ -2692,51 +2695,40 @@ def cleanup_directory_hooks(directory):
|
|
|
2692
2695
|
return 1, format_error(f"Cannot modify settings.local.json: {e}")
|
|
2693
2696
|
|
|
2694
2697
|
|
|
2695
|
-
def cmd_stop(
|
|
2696
|
-
"""Stop instances
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
if command.close_all_hooks:
|
|
2702
|
-
return cmd_cleanup('--all')
|
|
2703
|
-
else:
|
|
2704
|
-
return cmd_cleanup()
|
|
2705
|
-
|
|
2706
|
-
elif command.target == 'everything':
|
|
2707
|
-
# hcom stop everything: stop all + archive + remove hooks
|
|
2708
|
-
print("Stopping HCOM for all instances, archiving conversation, and removing hooks...")
|
|
2709
|
-
|
|
2710
|
-
# Stop all instances
|
|
2711
|
-
positions = load_all_positions()
|
|
2712
|
-
if positions:
|
|
2713
|
-
for instance_name in positions.keys():
|
|
2714
|
-
disable_instance(instance_name)
|
|
2715
|
-
print(f"Stopped HCOM for {len(positions)} instance(s)")
|
|
2716
|
-
|
|
2717
|
-
# Archive conversation
|
|
2718
|
-
clear_result = cmd_clear()
|
|
2698
|
+
def cmd_stop(argv: list[str]) -> int:
|
|
2699
|
+
"""Stop instances: hcom stop [alias|all] [--force] [--_hcom_session ID]"""
|
|
2700
|
+
# Parse arguments
|
|
2701
|
+
target = None
|
|
2702
|
+
force = '--force' in argv
|
|
2703
|
+
session_id = None
|
|
2719
2704
|
|
|
2720
|
-
|
|
2721
|
-
|
|
2705
|
+
# Extract --_hcom_session if present
|
|
2706
|
+
if '--_hcom_session' in argv:
|
|
2707
|
+
idx = argv.index('--_hcom_session')
|
|
2708
|
+
if idx + 1 < len(argv):
|
|
2709
|
+
session_id = argv[idx + 1]
|
|
2710
|
+
argv = argv[:idx] + argv[idx + 2:]
|
|
2722
2711
|
|
|
2723
|
-
|
|
2712
|
+
# Remove flags to get target
|
|
2713
|
+
args_without_flags = [a for a in argv if not a.startswith('--')]
|
|
2714
|
+
if args_without_flags:
|
|
2715
|
+
target = args_without_flags[0]
|
|
2724
2716
|
|
|
2725
|
-
|
|
2726
|
-
|
|
2717
|
+
# Handle 'all' target
|
|
2718
|
+
if target == 'all':
|
|
2727
2719
|
positions = load_all_positions()
|
|
2728
2720
|
|
|
2729
2721
|
if not positions:
|
|
2730
2722
|
print("No instances found")
|
|
2731
|
-
|
|
2732
|
-
return cmd_clear()
|
|
2723
|
+
return 0
|
|
2733
2724
|
|
|
2734
2725
|
stopped_count = 0
|
|
2735
2726
|
bg_logs = []
|
|
2727
|
+
stopped_names = []
|
|
2736
2728
|
for instance_name, instance_data in positions.items():
|
|
2737
2729
|
if instance_data.get('enabled', False):
|
|
2738
2730
|
disable_instance(instance_name)
|
|
2739
|
-
|
|
2731
|
+
stopped_names.append(instance_name)
|
|
2740
2732
|
stopped_count += 1
|
|
2741
2733
|
|
|
2742
2734
|
# Track background logs
|
|
@@ -2746,123 +2738,138 @@ def cmd_stop(command: StopCommand):
|
|
|
2746
2738
|
bg_logs.append((instance_name, log_file))
|
|
2747
2739
|
|
|
2748
2740
|
if stopped_count == 0:
|
|
2749
|
-
print("
|
|
2741
|
+
print("No instances to stop")
|
|
2750
2742
|
else:
|
|
2751
|
-
print(f"Stopped {stopped_count} instance(s)")
|
|
2743
|
+
print(f"Stopped {stopped_count} instance(s): {', '.join(stopped_names)}")
|
|
2752
2744
|
|
|
2753
2745
|
# Show background logs if any
|
|
2754
2746
|
if bg_logs:
|
|
2755
|
-
print(
|
|
2747
|
+
print()
|
|
2748
|
+
print("Background instance logs:")
|
|
2756
2749
|
for name, log_file in bg_logs:
|
|
2757
2750
|
print(f" {name}: {log_file}")
|
|
2758
|
-
print("\nMonitor: tail -f <log_file>")
|
|
2759
|
-
print("Force stop: hcom stop --force all")
|
|
2760
2751
|
|
|
2761
|
-
|
|
2762
|
-
return cmd_clear()
|
|
2752
|
+
return 0
|
|
2763
2753
|
|
|
2754
|
+
# Stop specific instance or self
|
|
2755
|
+
# Get instance name from injected session or target
|
|
2756
|
+
if session_id and not target:
|
|
2757
|
+
instance_name, _ = resolve_instance_name(session_id, get_config().tag)
|
|
2764
2758
|
else:
|
|
2765
|
-
|
|
2759
|
+
instance_name = target
|
|
2766
2760
|
|
|
2767
|
-
|
|
2768
|
-
if not check_and_update_hooks():
|
|
2769
|
-
return 1
|
|
2761
|
+
position = load_instance_position(instance_name) if instance_name else None
|
|
2770
2762
|
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2763
|
+
if not instance_name:
|
|
2764
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
2765
|
+
print("Error: Cannot determine instance", file=sys.stderr)
|
|
2766
|
+
print("Usage: Prompt Claude to run 'hcom stop' (or directly use: hcom stop <alias> or hcom stop all)", file=sys.stderr)
|
|
2775
2767
|
else:
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2768
|
+
print("Error: Alias required", file=sys.stderr)
|
|
2769
|
+
print("Usage: hcom stop <alias>", file=sys.stderr)
|
|
2770
|
+
print(" Or: hcom stop all", file=sys.stderr)
|
|
2771
|
+
print(" Or: prompt claude to run 'hcom stop' on itself", file=sys.stderr)
|
|
2772
|
+
positions = load_all_positions()
|
|
2773
|
+
visible = [alias for alias, data in positions.items() if should_show_in_watch(data)]
|
|
2774
|
+
if visible:
|
|
2775
|
+
print(f"Active aliases: {', '.join(sorted(visible))}", file=sys.stderr)
|
|
2776
|
+
return 1
|
|
2779
2777
|
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2778
|
+
if not position:
|
|
2779
|
+
print(f"No instance found for {instance_name}")
|
|
2780
|
+
return 1
|
|
2783
2781
|
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2782
|
+
# Skip already stopped instances (unless forcing)
|
|
2783
|
+
if not position.get('enabled', False) and not force:
|
|
2784
|
+
print(f"HCOM already stopped for {instance_name}")
|
|
2785
|
+
return 0
|
|
2787
2786
|
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
print(f"HCOM already stopped for {instance_name}")
|
|
2791
|
-
return 0
|
|
2787
|
+
# Disable instance (optionally with force)
|
|
2788
|
+
disable_instance(instance_name, force=force)
|
|
2792
2789
|
|
|
2793
|
-
|
|
2794
|
-
|
|
2790
|
+
if force:
|
|
2791
|
+
print(f"⚠️ Force stopped HCOM for {instance_name}.")
|
|
2792
|
+
print(f" Bash tool use is now DENIED.")
|
|
2793
|
+
print(f" To restart: hcom start {instance_name}")
|
|
2794
|
+
else:
|
|
2795
|
+
print(f"Stopped HCOM for {instance_name}. Will no longer receive chat messages automatically.")
|
|
2795
2796
|
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
print(f"
|
|
2797
|
+
# Show background log location if applicable
|
|
2798
|
+
if position.get('background'):
|
|
2799
|
+
log_file = position.get('background_log_file', '')
|
|
2800
|
+
if log_file:
|
|
2801
|
+
print(f"\nBackground log: {log_file}")
|
|
2802
|
+
print(f"Monitor: tail -f {log_file}")
|
|
2803
|
+
if not force:
|
|
2804
|
+
print(f"Force stop: hcom stop --force {instance_name}")
|
|
2802
2805
|
|
|
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}")
|
|
2806
|
+
return 0
|
|
2811
2807
|
|
|
2812
|
-
|
|
2808
|
+
def cmd_start(argv: list[str]) -> int:
|
|
2809
|
+
"""Enable HCOM participation: hcom start [alias] [--_hcom_session ID]"""
|
|
2810
|
+
# Parse arguments
|
|
2811
|
+
target = None
|
|
2812
|
+
session_id = None
|
|
2813
2813
|
|
|
2814
|
-
|
|
2815
|
-
|
|
2814
|
+
# Extract --_hcom_session if present
|
|
2815
|
+
if '--_hcom_session' in argv:
|
|
2816
|
+
idx = argv.index('--_hcom_session')
|
|
2817
|
+
if idx + 1 < len(argv):
|
|
2818
|
+
session_id = argv[idx + 1]
|
|
2819
|
+
argv = argv[:idx] + argv[idx + 2:]
|
|
2816
2820
|
|
|
2817
|
-
#
|
|
2818
|
-
if not
|
|
2819
|
-
|
|
2821
|
+
# Remove flags to get target
|
|
2822
|
+
args_without_flags = [a for a in argv if not a.startswith('--')]
|
|
2823
|
+
if args_without_flags:
|
|
2824
|
+
target = args_without_flags[0]
|
|
2820
2825
|
|
|
2821
2826
|
# Get instance name from injected session or target
|
|
2822
|
-
if
|
|
2823
|
-
instance_name, existing_data = resolve_instance_name(
|
|
2827
|
+
if session_id and not target:
|
|
2828
|
+
instance_name, existing_data = resolve_instance_name(session_id, get_config().tag)
|
|
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
|
-
print(f"
|
|
2835
|
+
print(f"\nStarted HCOM for {instance_name}")
|
|
2831
2836
|
else:
|
|
2832
2837
|
# Skip already started instances
|
|
2833
2838
|
if existing_data.get('enabled', False):
|
|
2834
2839
|
print(f"HCOM already started for {instance_name}")
|
|
2835
2840
|
return 0
|
|
2836
2841
|
|
|
2842
|
+
# Check if background instance has exited permanently
|
|
2843
|
+
if existing_data.get('session_ended') and existing_data.get('background'):
|
|
2844
|
+
session = existing_data.get('session_id', '')
|
|
2845
|
+
print(f"Cannot start {instance_name}: background instance has exited permanently")
|
|
2846
|
+
print(f"Background instances terminate when stopped and cannot be restarted")
|
|
2847
|
+
if session:
|
|
2848
|
+
print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
|
|
2849
|
+
return 1
|
|
2850
|
+
|
|
2837
2851
|
# Re-enabling existing instance
|
|
2838
2852
|
enable_instance(instance_name)
|
|
2839
|
-
print(f"Started HCOM for {instance_name}
|
|
2853
|
+
print(f"Started HCOM for {instance_name}")
|
|
2840
2854
|
|
|
2841
2855
|
return 0
|
|
2842
2856
|
|
|
2843
|
-
# Handle hooking target
|
|
2844
|
-
if command.target == 'hooking':
|
|
2845
|
-
# hcom start hooking: install hooks in current directory
|
|
2846
|
-
if setup_hooks():
|
|
2847
|
-
print("HCOM hooks installed in current directory")
|
|
2848
|
-
print("Hooks active on next Claude Code launch in this directory")
|
|
2849
|
-
return 0
|
|
2850
|
-
else:
|
|
2851
|
-
return 1
|
|
2852
|
-
|
|
2853
2857
|
# CLI path: start specific instance
|
|
2854
2858
|
positions = load_all_positions()
|
|
2855
2859
|
|
|
2856
2860
|
# Handle missing target from external CLI
|
|
2857
|
-
if not
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2861
|
+
if not target:
|
|
2862
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
2863
|
+
print("Error: Cannot determine instance", file=sys.stderr)
|
|
2864
|
+
print("Usage: Prompt Claude to run 'hcom start' (or: hcom start <alias>)", file=sys.stderr)
|
|
2865
|
+
else:
|
|
2866
|
+
print("Error: Alias required", file=sys.stderr)
|
|
2867
|
+
print("Usage: hcom start <alias> (or: prompt claude to run 'hcom start')", file=sys.stderr)
|
|
2868
|
+
print("To launch new instances: hcom <count>", file=sys.stderr)
|
|
2862
2869
|
return 1
|
|
2863
2870
|
|
|
2864
2871
|
# Start specific instance
|
|
2865
|
-
instance_name =
|
|
2872
|
+
instance_name = target
|
|
2866
2873
|
position = positions.get(instance_name)
|
|
2867
2874
|
|
|
2868
2875
|
if not position:
|
|
@@ -2874,17 +2881,81 @@ def cmd_start(command: StartCommand):
|
|
|
2874
2881
|
print(f"HCOM already started for {instance_name}")
|
|
2875
2882
|
return 0
|
|
2876
2883
|
|
|
2884
|
+
# Check if background instance has exited permanently
|
|
2885
|
+
if position.get('session_ended') and position.get('background'):
|
|
2886
|
+
session = position.get('session_id', '')
|
|
2887
|
+
print(f"Cannot start {instance_name}: background instance has exited permanently")
|
|
2888
|
+
print(f"Background instances terminate when stopped and cannot be restarted")
|
|
2889
|
+
if session:
|
|
2890
|
+
print(f"Resume conversation with same alias: hcom 1 claude -p --resume {session}")
|
|
2891
|
+
return 1
|
|
2892
|
+
|
|
2877
2893
|
# Enable instance (clears all stop flags)
|
|
2878
2894
|
enable_instance(instance_name)
|
|
2879
2895
|
|
|
2880
2896
|
print(f"Started HCOM for {instance_name}. Rejoined chat.")
|
|
2881
2897
|
return 0
|
|
2882
2898
|
|
|
2883
|
-
def
|
|
2899
|
+
def cmd_reset(argv: list[str]) -> int:
|
|
2900
|
+
"""Reset HCOM components: logs, hooks, config
|
|
2901
|
+
Usage:
|
|
2902
|
+
hcom reset # Everything (stop all + logs + hooks + config)
|
|
2903
|
+
hcom reset logs # Archive conversation only
|
|
2904
|
+
hcom reset hooks # Remove hooks only
|
|
2905
|
+
hcom reset config # Clear config (backup to config.env.TIMESTAMP)
|
|
2906
|
+
hcom reset logs hooks # Combine targets
|
|
2907
|
+
"""
|
|
2908
|
+
# No args = everything
|
|
2909
|
+
do_everything = not argv
|
|
2910
|
+
targets = argv if argv else ['logs', 'hooks', 'config']
|
|
2911
|
+
|
|
2912
|
+
# Validate targets
|
|
2913
|
+
valid = {'logs', 'hooks', 'config'}
|
|
2914
|
+
invalid = [t for t in targets if t not in valid]
|
|
2915
|
+
if invalid:
|
|
2916
|
+
print(f"Invalid target(s): {', '.join(invalid)}", file=sys.stderr)
|
|
2917
|
+
print("Valid targets: logs, hooks, config", file=sys.stderr)
|
|
2918
|
+
return 1
|
|
2919
|
+
|
|
2920
|
+
exit_codes = []
|
|
2921
|
+
|
|
2922
|
+
# Stop all instances if doing everything
|
|
2923
|
+
if do_everything:
|
|
2924
|
+
exit_codes.append(cmd_stop(['all']))
|
|
2925
|
+
|
|
2926
|
+
# Execute based on targets
|
|
2927
|
+
if 'logs' in targets:
|
|
2928
|
+
exit_codes.append(clear())
|
|
2929
|
+
|
|
2930
|
+
if 'hooks' in targets:
|
|
2931
|
+
exit_codes.append(cleanup('--all'))
|
|
2932
|
+
if remove_global_hooks():
|
|
2933
|
+
print("Removed hooks")
|
|
2934
|
+
else:
|
|
2935
|
+
print("Warning: Could not remove hooks. Check your claude settings.json file it might be invalid", file=sys.stderr)
|
|
2936
|
+
exit_codes.append(1)
|
|
2937
|
+
|
|
2938
|
+
if 'config' in targets:
|
|
2939
|
+
config_path = hcom_path(CONFIG_FILE)
|
|
2940
|
+
if config_path.exists():
|
|
2941
|
+
# Backup with timestamp
|
|
2942
|
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
2943
|
+
backup_path = hcom_path(f'config.env.{timestamp}')
|
|
2944
|
+
shutil.copy2(config_path, backup_path)
|
|
2945
|
+
config_path.unlink()
|
|
2946
|
+
print(f"Config backed up to config.env.{timestamp} and cleared")
|
|
2947
|
+
exit_codes.append(0)
|
|
2948
|
+
else:
|
|
2949
|
+
print("No config file to clear")
|
|
2950
|
+
exit_codes.append(0)
|
|
2951
|
+
|
|
2952
|
+
return max(exit_codes) if exit_codes else 0
|
|
2953
|
+
|
|
2954
|
+
def cleanup(*args: str) -> int:
|
|
2884
2955
|
"""Remove hcom hooks from current directory or all directories"""
|
|
2885
2956
|
if args and args[0] == '--all':
|
|
2886
2957
|
directories = set()
|
|
2887
|
-
|
|
2958
|
+
|
|
2888
2959
|
# Get all directories from current instances
|
|
2889
2960
|
try:
|
|
2890
2961
|
positions = load_all_positions()
|
|
@@ -2894,6 +2965,24 @@ def cmd_cleanup(*args):
|
|
|
2894
2965
|
directories.add(instance_data['directory'])
|
|
2895
2966
|
except Exception as e:
|
|
2896
2967
|
print(f"Warning: Could not read current instances: {e}")
|
|
2968
|
+
|
|
2969
|
+
# Also check archived instances for directories (until 0.5.0)
|
|
2970
|
+
try:
|
|
2971
|
+
archive_dir = hcom_path(ARCHIVE_DIR)
|
|
2972
|
+
if archive_dir.exists():
|
|
2973
|
+
for session_dir in archive_dir.iterdir():
|
|
2974
|
+
if session_dir.is_dir() and session_dir.name.startswith('session-'):
|
|
2975
|
+
instances_dir = session_dir / 'instances'
|
|
2976
|
+
if instances_dir.exists():
|
|
2977
|
+
for instance_file in instances_dir.glob('*.json'):
|
|
2978
|
+
try:
|
|
2979
|
+
data = json.loads(instance_file.read_text())
|
|
2980
|
+
if 'directory' in data:
|
|
2981
|
+
directories.add(data['directory'])
|
|
2982
|
+
except Exception:
|
|
2983
|
+
pass
|
|
2984
|
+
except Exception as e:
|
|
2985
|
+
print(f"Warning: Could not read archived instances: {e}")
|
|
2897
2986
|
|
|
2898
2987
|
if not directories:
|
|
2899
2988
|
print("No directories found in current HCOM tracking")
|
|
@@ -2937,42 +3026,61 @@ def cmd_cleanup(*args):
|
|
|
2937
3026
|
print(message)
|
|
2938
3027
|
return exit_code
|
|
2939
3028
|
|
|
2940
|
-
def
|
|
2941
|
-
"""
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
return True # Not in Claude Code, continue normally
|
|
3029
|
+
def ensure_hooks_current() -> bool:
|
|
3030
|
+
"""Ensure hooks match current execution context - called on EVERY command.
|
|
3031
|
+
Auto-updates hooks if execution context changes (e.g., pip → uvx).
|
|
3032
|
+
Always returns True (warns but never blocks - Claude Code is fault-tolerant)."""
|
|
2945
3033
|
|
|
2946
|
-
#
|
|
2947
|
-
|
|
2948
|
-
home_settings = Path.home() / '.claude' / 'settings.local.json'
|
|
3034
|
+
# Verify hooks exist and match current execution context
|
|
3035
|
+
global_settings = get_claude_settings_path()
|
|
2949
3036
|
|
|
2950
|
-
#
|
|
2951
|
-
|
|
2952
|
-
|
|
3037
|
+
# Check if hooks are valid (exist + env var matches current context)
|
|
3038
|
+
hooks_exist = verify_hooks_installed(global_settings)
|
|
3039
|
+
env_var_matches = False
|
|
2953
3040
|
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
3041
|
+
if hooks_exist:
|
|
3042
|
+
try:
|
|
3043
|
+
settings = load_settings_json(global_settings, default={})
|
|
3044
|
+
if settings is None:
|
|
3045
|
+
settings = {}
|
|
3046
|
+
current_hcom = _build_hcom_env_value()
|
|
3047
|
+
installed_hcom = settings.get('env', {}).get('HCOM')
|
|
3048
|
+
env_var_matches = (installed_hcom == current_hcom)
|
|
3049
|
+
except Exception:
|
|
3050
|
+
# Failed to read settings - try to fix by updating
|
|
3051
|
+
env_var_matches = False
|
|
2962
3052
|
|
|
2963
|
-
|
|
2964
|
-
|
|
3053
|
+
# Install/update hooks if missing or env var wrong
|
|
3054
|
+
if not hooks_exist or not env_var_matches:
|
|
3055
|
+
try:
|
|
3056
|
+
setup_hooks()
|
|
3057
|
+
if os.environ.get('CLAUDECODE') == '1':
|
|
3058
|
+
print("HCOM hooks updated. Please restart Claude Code to apply changes.", file=sys.stderr)
|
|
3059
|
+
print("=" * 60, file=sys.stderr)
|
|
3060
|
+
except Exception as e:
|
|
3061
|
+
# Failed to verify/update hooks, but they might still work
|
|
3062
|
+
# Claude Code is fault-tolerant with malformed JSON
|
|
3063
|
+
print(f"⚠️ Could not verify/update hooks: {e}", file=sys.stderr)
|
|
3064
|
+
print("If HCOM doesn't work, check ~/.claude/settings.json", file=sys.stderr)
|
|
2965
3065
|
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
3066
|
+
return True
|
|
3067
|
+
|
|
3068
|
+
def cmd_send(argv: list[str], force_cli: bool = False, quiet: bool = False) -> int:
|
|
3069
|
+
"""Send message to hcom: hcom send "message" [--_hcom_session ID]"""
|
|
3070
|
+
# Parse message and session_id
|
|
3071
|
+
message = None
|
|
3072
|
+
session_id = None
|
|
2969
3073
|
|
|
2970
|
-
#
|
|
2971
|
-
if
|
|
2972
|
-
|
|
2973
|
-
|
|
3074
|
+
# Extract --_hcom_session if present (injected by PreToolUse hook)
|
|
3075
|
+
if '--_hcom_session' in argv:
|
|
3076
|
+
idx = argv.index('--_hcom_session')
|
|
3077
|
+
if idx + 1 < len(argv):
|
|
3078
|
+
session_id = argv[idx + 1]
|
|
3079
|
+
argv = argv[:idx] + argv[idx + 2:] # Remove flag and value
|
|
2974
3080
|
|
|
2975
|
-
|
|
3081
|
+
# First non-flag argument is the message
|
|
3082
|
+
if argv:
|
|
3083
|
+
message = argv[0]
|
|
2976
3084
|
|
|
2977
3085
|
# Check message is provided
|
|
2978
3086
|
if not message:
|
|
@@ -2984,7 +3092,7 @@ def cmd_send(command: SendCommand, force_cli=False):
|
|
|
2984
3092
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2985
3093
|
|
|
2986
3094
|
if not log_file.exists() and not instances_dir.exists():
|
|
2987
|
-
print(format_error("No conversation found", "Run 'hcom
|
|
3095
|
+
print(format_error("No conversation found", "Run 'hcom <count>' first"), file=sys.stderr)
|
|
2988
3096
|
return 1
|
|
2989
3097
|
|
|
2990
3098
|
# Validate message
|
|
@@ -2999,7 +3107,7 @@ def cmd_send(command: SendCommand, force_cli=False):
|
|
|
2999
3107
|
try:
|
|
3000
3108
|
positions = load_all_positions()
|
|
3001
3109
|
all_instances = list(positions.keys())
|
|
3002
|
-
sender_name =
|
|
3110
|
+
sender_name = SENDER
|
|
3003
3111
|
all_names = all_instances + [sender_name]
|
|
3004
3112
|
unmatched = [m for m in mentions
|
|
3005
3113
|
if not any(name.lower().startswith(m.lower()) for name in all_names)]
|
|
@@ -3009,19 +3117,17 @@ def cmd_send(command: SendCommand, force_cli=False):
|
|
|
3009
3117
|
pass # Don't fail on warning
|
|
3010
3118
|
|
|
3011
3119
|
# Determine sender from injected session_id or CLI
|
|
3012
|
-
if
|
|
3013
|
-
# Instance context -
|
|
3120
|
+
if session_id and not force_cli:
|
|
3121
|
+
# Instance context - resolve name from session_id (searches existing instances first)
|
|
3014
3122
|
try:
|
|
3015
|
-
sender_name =
|
|
3123
|
+
sender_name, instance_data = resolve_instance_name(session_id, get_config().tag)
|
|
3016
3124
|
except (ValueError, Exception) as e:
|
|
3017
3125
|
print(format_error(f"Invalid session_id: {e}"), file=sys.stderr)
|
|
3018
3126
|
return 1
|
|
3019
3127
|
|
|
3020
|
-
instance_data = load_instance_position(sender_name)
|
|
3021
|
-
|
|
3022
3128
|
# Initialize instance if doesn't exist (first use)
|
|
3023
3129
|
if not instance_data:
|
|
3024
|
-
initialize_instance_in_position_file(sender_name,
|
|
3130
|
+
initialize_instance_in_position_file(sender_name, session_id)
|
|
3025
3131
|
instance_data = load_instance_position(sender_name)
|
|
3026
3132
|
|
|
3027
3133
|
# Check force_closed
|
|
@@ -3042,88 +3148,40 @@ def cmd_send(command: SendCommand, force_cli=False):
|
|
|
3042
3148
|
# Show unread messages
|
|
3043
3149
|
messages = get_unread_messages(sender_name, update_position=True)
|
|
3044
3150
|
if messages:
|
|
3045
|
-
max_msgs =
|
|
3151
|
+
max_msgs = MAX_MESSAGES_PER_DELIVERY
|
|
3046
3152
|
formatted = format_hook_messages(messages[:max_msgs], sender_name)
|
|
3047
3153
|
print(f"Message sent\n\n{formatted}", file=sys.stderr)
|
|
3048
3154
|
else:
|
|
3049
3155
|
print("Message sent", file=sys.stderr)
|
|
3050
3156
|
|
|
3051
|
-
# Show cli_hints if configured (non-interactive mode)
|
|
3052
|
-
if not is_interactive():
|
|
3053
|
-
show_cli_hints()
|
|
3054
|
-
|
|
3055
3157
|
return 0
|
|
3056
3158
|
else:
|
|
3057
3159
|
# CLI context - no session_id or force_cli=True
|
|
3058
|
-
|
|
3160
|
+
|
|
3161
|
+
# Warn if inside Claude Code but no session_id (hooks not working)
|
|
3162
|
+
if os.environ.get('CLAUDECODE') == '1' and not session_id and not force_cli:
|
|
3163
|
+
print(f"⚠️ Cannot determine alias - message sent as '{SENDER}'", file=sys.stderr)
|
|
3164
|
+
print(" Prompt Claude to send a hcom message instead of using bash mode (! prefix).", file=sys.stderr)
|
|
3165
|
+
|
|
3166
|
+
|
|
3167
|
+
sender_name = SENDER
|
|
3059
3168
|
|
|
3060
3169
|
if not send_message(sender_name, message):
|
|
3061
3170
|
print(format_error("Failed to send message"), file=sys.stderr)
|
|
3062
3171
|
return 1
|
|
3063
3172
|
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
# Show cli_hints if configured (non-interactive mode)
|
|
3067
|
-
if not is_interactive():
|
|
3068
|
-
show_cli_hints()
|
|
3173
|
+
if not quiet:
|
|
3174
|
+
print(f"✓ Sent from {sender_name}", file=sys.stderr)
|
|
3069
3175
|
|
|
3070
3176
|
return 0
|
|
3071
3177
|
|
|
3072
|
-
def
|
|
3178
|
+
def send_cli(message: str, quiet: bool = False) -> int:
|
|
3073
3179
|
"""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
|
|
3180
|
+
return cmd_send([message], force_cli=True, quiet=quiet)
|
|
3123
3181
|
|
|
3124
3182
|
# ==================== Hook Helpers ====================
|
|
3125
3183
|
|
|
3126
|
-
def format_hook_messages(messages, instance_name):
|
|
3184
|
+
def format_hook_messages(messages: list[dict[str, str]], instance_name: str) -> str:
|
|
3127
3185
|
"""Format messages for hook feedback"""
|
|
3128
3186
|
if len(messages) == 1:
|
|
3129
3187
|
msg = messages[0]
|
|
@@ -3132,100 +3190,42 @@ def format_hook_messages(messages, instance_name):
|
|
|
3132
3190
|
parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
|
|
3133
3191
|
reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
|
|
3134
3192
|
|
|
3135
|
-
# Only append
|
|
3136
|
-
|
|
3137
|
-
if
|
|
3138
|
-
reason = f"{reason} | [{
|
|
3193
|
+
# Only append hints to messages
|
|
3194
|
+
hints = get_config().hints
|
|
3195
|
+
if hints:
|
|
3196
|
+
reason = f"{reason} | [{hints}]"
|
|
3139
3197
|
|
|
3140
3198
|
return reason
|
|
3141
3199
|
|
|
3142
3200
|
# ==================== Hook Handlers ====================
|
|
3143
3201
|
|
|
3144
|
-
def
|
|
3145
|
-
hook_type: str | None,
|
|
3146
|
-
session_id: str,
|
|
3147
|
-
source: str,
|
|
3148
|
-
existing_data: dict | None
|
|
3149
|
-
) -> SessionScenario | None:
|
|
3202
|
+
def init_hook_context(hook_data: dict[str, Any], hook_type: str | None = None) -> tuple[str, dict[str, Any], bool]:
|
|
3150
3203
|
"""
|
|
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:
|
|
3204
|
+
Initialize instance context. Flow:
|
|
3194
3205
|
1. Resolve instance name (search by session_id, generate if not found)
|
|
3195
|
-
2.
|
|
3196
|
-
3.
|
|
3197
|
-
4. Return
|
|
3206
|
+
2. Create instance file if fresh start in UserPromptSubmit
|
|
3207
|
+
3. Build updates dict
|
|
3208
|
+
4. Return (instance_name, updates, is_matched_resume)
|
|
3198
3209
|
"""
|
|
3199
3210
|
session_id = hook_data.get('session_id', '')
|
|
3200
3211
|
transcript_path = hook_data.get('transcript_path', '')
|
|
3201
|
-
|
|
3202
|
-
prefix = os.environ.get('HCOM_PREFIX')
|
|
3212
|
+
tag = get_config().tag
|
|
3203
3213
|
|
|
3204
|
-
#
|
|
3205
|
-
instance_name, existing_data = resolve_instance_name(session_id,
|
|
3214
|
+
# Resolve instance name - existing_data is None for fresh starts
|
|
3215
|
+
instance_name, existing_data = resolve_instance_name(session_id, tag)
|
|
3206
3216
|
|
|
3207
3217
|
# Save migrated data if we have it
|
|
3208
3218
|
if existing_data:
|
|
3209
3219
|
save_instance_position(instance_name, existing_data)
|
|
3210
3220
|
|
|
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:
|
|
3221
|
+
# Create instance file if fresh start in UserPromptSubmit
|
|
3222
|
+
if existing_data is None and hook_type == 'userpromptsubmit':
|
|
3224
3223
|
initialize_instance_in_position_file(instance_name, session_id)
|
|
3225
3224
|
|
|
3226
|
-
#
|
|
3225
|
+
# Build updates dict
|
|
3227
3226
|
updates: dict[str, Any] = {
|
|
3228
3227
|
'directory': str(Path.cwd()),
|
|
3228
|
+
'tag': tag,
|
|
3229
3229
|
}
|
|
3230
3230
|
|
|
3231
3231
|
if session_id:
|
|
@@ -3239,11 +3239,10 @@ def init_hook_context(hook_data, hook_type=None):
|
|
|
3239
3239
|
updates['background'] = True
|
|
3240
3240
|
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
3241
3241
|
|
|
3242
|
-
#
|
|
3243
|
-
|
|
3242
|
+
# Simple boolean: matched resume if existing_data found
|
|
3243
|
+
is_matched_resume = (existing_data is not None)
|
|
3244
3244
|
|
|
3245
|
-
|
|
3246
|
-
return instance_name, updates, is_resume_match, is_new_instance
|
|
3245
|
+
return instance_name, updates, is_matched_resume
|
|
3247
3246
|
|
|
3248
3247
|
def pretooluse_decision(decision: str, reason: str) -> None:
|
|
3249
3248
|
"""Exit PreToolUse hook with permission decision"""
|
|
@@ -3255,9 +3254,9 @@ def pretooluse_decision(decision: str, reason: str) -> None:
|
|
|
3255
3254
|
}
|
|
3256
3255
|
}
|
|
3257
3256
|
print(json.dumps(output, ensure_ascii=False))
|
|
3258
|
-
sys.exit(
|
|
3257
|
+
sys.exit(0)
|
|
3259
3258
|
|
|
3260
|
-
def handle_pretooluse(hook_data,
|
|
3259
|
+
def handle_pretooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
3261
3260
|
"""Handle PreToolUse hook - check force_closed, inject session_id"""
|
|
3262
3261
|
instance_data = load_instance_position(instance_name)
|
|
3263
3262
|
tool_name = hook_data.get('tool_name', '')
|
|
@@ -3271,18 +3270,16 @@ def handle_pretooluse(hook_data, instance_name, updates):
|
|
|
3271
3270
|
if instance_data.get('enabled', False):
|
|
3272
3271
|
set_status(instance_name, 'tool_pending', tool_name)
|
|
3273
3272
|
|
|
3274
|
-
# Inject session_id into hcom
|
|
3273
|
+
# Inject session_id into hcom commands via updatedInput
|
|
3275
3274
|
if tool_name == 'Bash' and session_id:
|
|
3276
3275
|
command = hook_data.get('tool_input', {}).get('command', '')
|
|
3277
3276
|
|
|
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)
|
|
3277
|
+
# Match hcom commands for session_id injection and auto-approval
|
|
3278
|
+
matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
|
|
3279
|
+
if matches:
|
|
3280
|
+
# Inject all if chained (&&, ||, ;, |), otherwise first only (avoids quoted text in messages)
|
|
3281
|
+
inject_all = len(matches) > 1 and any(op in command[matches[0].end():matches[1].start()] for op in ['&&', '||', ';', '|'])
|
|
3282
|
+
modified_command = HCOM_COMMAND_PATTERN.sub(rf'\g<0> --_hcom_session {session_id}', command, count=0 if inject_all else 1)
|
|
3286
3283
|
|
|
3287
3284
|
output = {
|
|
3288
3285
|
"hookSpecificOutput": {
|
|
@@ -3294,17 +3291,16 @@ def handle_pretooluse(hook_data, instance_name, updates):
|
|
|
3294
3291
|
}
|
|
3295
3292
|
}
|
|
3296
3293
|
print(json.dumps(output, ensure_ascii=False))
|
|
3297
|
-
sys.exit(
|
|
3294
|
+
sys.exit(0)
|
|
3298
3295
|
|
|
3299
3296
|
|
|
3300
3297
|
|
|
3301
|
-
def handle_stop(hook_data, instance_name, updates):
|
|
3298
|
+
def handle_stop(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3302
3299
|
"""Handle Stop hook - poll for messages and deliver"""
|
|
3303
3300
|
|
|
3304
3301
|
try:
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
timeout = get_config_value('wait_timeout', 1800)
|
|
3302
|
+
updates['last_stop'] = time.time()
|
|
3303
|
+
timeout = get_config().timeout
|
|
3308
3304
|
updates['wait_timeout'] = timeout
|
|
3309
3305
|
set_status(instance_name, 'waiting')
|
|
3310
3306
|
|
|
@@ -3316,39 +3312,48 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
3316
3312
|
start_time = time.time()
|
|
3317
3313
|
|
|
3318
3314
|
try:
|
|
3319
|
-
|
|
3315
|
+
first_poll = True
|
|
3320
3316
|
last_heartbeat = start_time
|
|
3321
3317
|
# Actual polling loop - this IS the holding pattern
|
|
3322
3318
|
while time.time() - start_time < timeout:
|
|
3323
|
-
if
|
|
3324
|
-
|
|
3325
|
-
loop_count += 1
|
|
3319
|
+
if first_poll:
|
|
3320
|
+
first_poll = False
|
|
3326
3321
|
|
|
3327
|
-
#
|
|
3322
|
+
# Reload instance data each poll iteration
|
|
3328
3323
|
instance_data = load_instance_position(instance_name)
|
|
3329
3324
|
|
|
3325
|
+
# Check flag file FIRST (highest priority coordination signal)
|
|
3326
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
3327
|
+
if flag_file.exists():
|
|
3328
|
+
try:
|
|
3329
|
+
flag_file.unlink()
|
|
3330
|
+
except (FileNotFoundError, PermissionError):
|
|
3331
|
+
# Already deleted or locked, continue anyway
|
|
3332
|
+
pass
|
|
3333
|
+
sys.exit(0)
|
|
3334
|
+
|
|
3330
3335
|
# Check if session ended (SessionEnd hook fired) - exit without changing status
|
|
3331
3336
|
if instance_data.get('session_ended'):
|
|
3332
|
-
sys.exit(
|
|
3337
|
+
sys.exit(0) # Don't overwrite session_ended status
|
|
3333
3338
|
|
|
3334
|
-
# Check if user input is pending - exit cleanly if recent input
|
|
3339
|
+
# Check if user input is pending (timestamp fallback) - exit cleanly if recent input
|
|
3335
3340
|
last_user_input = instance_data.get('last_user_input', 0)
|
|
3336
3341
|
if time.time() - last_user_input < 0.2:
|
|
3337
|
-
sys.exit(
|
|
3342
|
+
sys.exit(0) # Don't overwrite status - let current status remain
|
|
3338
3343
|
|
|
3339
|
-
# Check if
|
|
3344
|
+
# Check if stopped/disabled - exit cleanly
|
|
3340
3345
|
if not instance_data.get('enabled', False):
|
|
3341
|
-
sys.exit(
|
|
3346
|
+
sys.exit(0) # Preserve 'stopped' status set by cmd_stop
|
|
3342
3347
|
|
|
3343
3348
|
# Check for new messages and deliver
|
|
3344
3349
|
if messages := get_unread_messages(instance_name, update_position=True):
|
|
3345
|
-
messages_to_show = messages[:
|
|
3350
|
+
messages_to_show = messages[:MAX_MESSAGES_PER_DELIVERY]
|
|
3346
3351
|
reason = format_hook_messages(messages_to_show, instance_name)
|
|
3347
3352
|
set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
|
|
3348
3353
|
|
|
3349
3354
|
output = {"decision": "block", "reason": reason}
|
|
3350
3355
|
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
3351
|
-
sys.exit(
|
|
3356
|
+
sys.exit(2)
|
|
3352
3357
|
|
|
3353
3358
|
# Update heartbeat every 0.5 seconds for staleness detection
|
|
3354
3359
|
now = time.time()
|
|
@@ -3367,51 +3372,70 @@ def handle_stop(hook_data, instance_name, updates):
|
|
|
3367
3372
|
|
|
3368
3373
|
# Timeout reached
|
|
3369
3374
|
set_status(instance_name, 'timeout')
|
|
3375
|
+
sys.exit(0)
|
|
3370
3376
|
|
|
3371
3377
|
except Exception as e:
|
|
3372
3378
|
# Log error and exit gracefully
|
|
3373
3379
|
log_hook_error('handle_stop', e)
|
|
3374
|
-
sys.exit(
|
|
3380
|
+
sys.exit(0) # Preserve previous status on exception
|
|
3375
3381
|
|
|
3376
|
-
def handle_notify(hook_data, instance_name, updates):
|
|
3382
|
+
def handle_notify(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3377
3383
|
"""Handle Notification hook - track permission requests"""
|
|
3378
3384
|
updates['notification_message'] = hook_data.get('message', '')
|
|
3379
3385
|
update_instance_position(instance_name, updates)
|
|
3380
3386
|
set_status(instance_name, 'blocked', hook_data.get('message', ''))
|
|
3381
3387
|
|
|
3382
|
-
def
|
|
3383
|
-
"""
|
|
3388
|
+
def get_user_input_flag_file(instance_name: str) -> Path:
|
|
3389
|
+
"""Get path to user input coordination flag file"""
|
|
3390
|
+
return hcom_path(FLAGS_DIR, f'{instance_name}.user_input')
|
|
3391
|
+
|
|
3392
|
+
def wait_for_stop_exit(instance_name: str, max_wait: float = 0.2) -> int:
|
|
3393
|
+
"""
|
|
3394
|
+
Wait for Stop hook to exit using flag file coordination.
|
|
3395
|
+
Returns wait time in ms.
|
|
3396
|
+
Strategy:
|
|
3397
|
+
1. Create flag file
|
|
3398
|
+
2. Wait for Stop hook to delete it (proof it exited)
|
|
3399
|
+
3. Fallback to timeout if Stop hook doesn't delete flag
|
|
3400
|
+
"""
|
|
3384
3401
|
start = time.time()
|
|
3402
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
3385
3403
|
|
|
3386
|
-
|
|
3404
|
+
# Wait for flag file to be deleted by Stop hook
|
|
3405
|
+
while flag_file.exists() and time.time() - start < max_wait:
|
|
3387
3406
|
time.sleep(0.01)
|
|
3388
3407
|
|
|
3389
|
-
data = load_instance_position(instance_name)
|
|
3390
|
-
last_stop_age = time.time() - data.get('last_stop', 0)
|
|
3391
|
-
|
|
3392
|
-
if last_stop_age > 0.2:
|
|
3393
|
-
return int((time.time() - start) * 1000)
|
|
3394
|
-
|
|
3395
3408
|
return int((time.time() - start) * 1000)
|
|
3396
3409
|
|
|
3397
|
-
def handle_userpromptsubmit(hook_data, instance_name, updates,
|
|
3410
|
+
def handle_userpromptsubmit(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], is_matched_resume: bool, instance_data: dict[str, Any] | None) -> None:
|
|
3398
3411
|
"""Handle UserPromptSubmit hook - track when user sends messages"""
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3412
|
+
is_enabled = instance_data.get('enabled', False) if instance_data else False
|
|
3413
|
+
last_stop = instance_data.get('last_stop', 0) if instance_data else 0
|
|
3414
|
+
alias_announced = instance_data.get('alias_announced', False) if instance_data else False
|
|
3415
|
+
|
|
3416
|
+
# Session_ended prevents user receiving messages(?) so reset it.
|
|
3417
|
+
if is_matched_resume and instance_data and instance_data.get('session_ended'):
|
|
3418
|
+
update_instance_position(instance_name, {'session_ended': False})
|
|
3419
|
+
instance_data['session_ended'] = False # Resume path reactivates Stop hook polling
|
|
3404
3420
|
|
|
3405
3421
|
# Coordinate with Stop hook only if enabled AND Stop hook is active
|
|
3406
3422
|
stop_is_active = (time.time() - last_stop) < 1.0
|
|
3407
3423
|
|
|
3408
3424
|
if is_enabled and stop_is_active:
|
|
3425
|
+
# Create flag file for coordination
|
|
3426
|
+
flag_file = get_user_input_flag_file(instance_name)
|
|
3427
|
+
try:
|
|
3428
|
+
flag_file.touch()
|
|
3429
|
+
except (OSError, PermissionError):
|
|
3430
|
+
# Failed to create flag, fall back to timestamp-only coordination
|
|
3431
|
+
pass
|
|
3432
|
+
|
|
3433
|
+
# Set timestamp (backup mechanism)
|
|
3409
3434
|
updates['last_user_input'] = time.time()
|
|
3410
3435
|
update_instance_position(instance_name, updates)
|
|
3411
|
-
wait_ms = wait_for_stop_exit(instance_name)
|
|
3412
3436
|
|
|
3413
|
-
|
|
3414
|
-
|
|
3437
|
+
# Wait for Stop hook to delete flag file
|
|
3438
|
+
wait_for_stop_exit(instance_name)
|
|
3415
3439
|
|
|
3416
3440
|
# Build message based on what happened
|
|
3417
3441
|
msg = None
|
|
@@ -3419,18 +3443,8 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
|
|
|
3419
3443
|
# Determine if this is an HCOM-launched instance
|
|
3420
3444
|
is_hcom_launched = os.environ.get('HCOM_LAUNCHED') == '1'
|
|
3421
3445
|
|
|
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:
|
|
3446
|
+
# Show bootstrap if not already announced
|
|
3447
|
+
if not alias_announced:
|
|
3434
3448
|
if is_hcom_launched:
|
|
3435
3449
|
# HCOM-launched instance - show bootstrap immediately
|
|
3436
3450
|
msg = build_hcom_bootstrap_text(instance_name)
|
|
@@ -3445,54 +3459,76 @@ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match,
|
|
|
3445
3459
|
msg += build_hcom_bootstrap_text(instance_name)
|
|
3446
3460
|
update_instance_position(instance_name, {'alias_announced': True})
|
|
3447
3461
|
|
|
3448
|
-
#
|
|
3449
|
-
if msg and
|
|
3462
|
+
# Add resume status note if we showed bootstrap for a matched resume
|
|
3463
|
+
if msg and is_matched_resume:
|
|
3450
3464
|
if is_enabled:
|
|
3451
|
-
msg += "\n[Session resumed.
|
|
3452
|
-
else:
|
|
3453
|
-
msg += "\n[Session resumed. HCOM stopped for this instance - will not receive chat messages. Run 'hcom start' to rejoin chat. Your alias and conversation history preserved.]"
|
|
3454
|
-
|
|
3465
|
+
msg += "\n[HCOM Session resumed. Your alias and conversation history preserved.]"
|
|
3455
3466
|
if msg:
|
|
3456
3467
|
output = {
|
|
3457
|
-
# "systemMessage": "HCOM enabled",
|
|
3458
3468
|
"hookSpecificOutput": {
|
|
3459
3469
|
"hookEventName": "UserPromptSubmit",
|
|
3460
3470
|
"additionalContext": msg
|
|
3461
3471
|
}
|
|
3462
3472
|
}
|
|
3463
3473
|
print(json.dumps(output), file=sys.stdout)
|
|
3464
|
-
# sys.exit(1)
|
|
3465
|
-
|
|
3466
|
-
def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
|
|
3467
|
-
"""Handle SessionStart hook - minimal message, full details on first prompt"""
|
|
3468
|
-
source = hook_data.get('source', 'startup')
|
|
3469
|
-
|
|
3470
|
-
# Update instance if it exists (matched resume only, since we don't create in SessionStart anymore)
|
|
3471
|
-
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
3472
|
-
if instance_file.exists():
|
|
3473
|
-
updates['alias_announced'] = False
|
|
3474
|
-
update_instance_position(instance_name, updates)
|
|
3475
|
-
set_status(instance_name, 'session_start')
|
|
3476
|
-
|
|
3477
|
-
# Minimal message - no alias yet (UserPromptSubmit will show full details)
|
|
3478
|
-
help_text = "[HCOM active. Submit a prompt to initialize.]"
|
|
3479
3474
|
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3475
|
+
def handle_sessionstart(hook_data: dict[str, Any]) -> None:
|
|
3476
|
+
"""Handle SessionStart hook - initial msg & reads environment variables"""
|
|
3477
|
+
# Only show message for HCOM-launched instances
|
|
3478
|
+
if os.environ.get('HCOM_LAUNCHED') == '1':
|
|
3479
|
+
parts = f"[HCOM is started, you can send messages with the command: {build_hcom_command()} send]"
|
|
3480
|
+
else:
|
|
3481
|
+
parts = f"[You can start HCOM with the command: {build_hcom_command()} start]"
|
|
3485
3482
|
|
|
3486
3483
|
output = {
|
|
3487
3484
|
"hookSpecificOutput": {
|
|
3488
3485
|
"hookEventName": "SessionStart",
|
|
3489
|
-
"additionalContext":
|
|
3486
|
+
"additionalContext": parts
|
|
3490
3487
|
}
|
|
3491
3488
|
}
|
|
3492
3489
|
|
|
3493
3490
|
print(json.dumps(output))
|
|
3494
3491
|
|
|
3495
|
-
def
|
|
3492
|
+
def handle_posttooluse(hook_data: dict[str, Any], instance_name: str) -> None:
|
|
3493
|
+
"""Handle PostToolUse hook - show launch context or bootstrap"""
|
|
3494
|
+
command = hook_data.get('tool_input', {}).get('command', '')
|
|
3495
|
+
instance_data = load_instance_position(instance_name)
|
|
3496
|
+
|
|
3497
|
+
# Check for help or launch commands (combined pattern)
|
|
3498
|
+
if re.search(r'\bhcom\s+(?:(?:help|--help|-h)\b|\d+)', command):
|
|
3499
|
+
if not instance_data.get('launch_context_announced', False):
|
|
3500
|
+
msg = build_launch_context(instance_name)
|
|
3501
|
+
update_instance_position(instance_name, {'launch_context_announced': True})
|
|
3502
|
+
|
|
3503
|
+
output = {
|
|
3504
|
+
"hookSpecificOutput": {
|
|
3505
|
+
"hookEventName": "PostToolUse",
|
|
3506
|
+
"additionalContext": msg
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
3510
|
+
return
|
|
3511
|
+
|
|
3512
|
+
# Check HCOM_COMMAND_PATTERN for bootstrap (other hcom commands)
|
|
3513
|
+
matches = list(re.finditer(HCOM_COMMAND_PATTERN, command))
|
|
3514
|
+
|
|
3515
|
+
if not matches:
|
|
3516
|
+
return
|
|
3517
|
+
|
|
3518
|
+
# Show bootstrap if not announced yet
|
|
3519
|
+
if not instance_data.get('alias_announced', False):
|
|
3520
|
+
msg = build_hcom_bootstrap_text(instance_name)
|
|
3521
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
3522
|
+
|
|
3523
|
+
output = {
|
|
3524
|
+
"hookSpecificOutput": {
|
|
3525
|
+
"hookEventName": "PostToolUse",
|
|
3526
|
+
"additionalContext": msg
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
3530
|
+
|
|
3531
|
+
def handle_sessionend(hook_data: dict[str, Any], instance_name: str, updates: dict[str, Any], instance_data: dict[str, Any] | None) -> None:
|
|
3496
3532
|
"""Handle SessionEnd hook - mark session as ended and set final status"""
|
|
3497
3533
|
reason = hook_data.get('reason', 'unknown')
|
|
3498
3534
|
|
|
@@ -3507,138 +3543,139 @@ def handle_sessionend(hook_data, instance_name, updates):
|
|
|
3507
3543
|
except Exception as e:
|
|
3508
3544
|
log_hook_error(f'sessionend:update_instance_position({instance_name})', e)
|
|
3509
3545
|
|
|
3546
|
+
def should_skip_vanilla_instance(hook_type: str, hook_data: dict) -> bool:
|
|
3547
|
+
"""
|
|
3548
|
+
Returns True if hook should exit early.
|
|
3549
|
+
Vanilla instances (not HCOM-launched) exit early unless:
|
|
3550
|
+
- Enabled
|
|
3551
|
+
- PreToolUse (handles opt-in)
|
|
3552
|
+
- UserPromptSubmit with hcom command in prompt (shows preemptive bootstrap)
|
|
3553
|
+
"""
|
|
3554
|
+
# PreToolUse always runs (handles toggle commands)
|
|
3555
|
+
# HCOM-launched instances always run
|
|
3556
|
+
if hook_type == 'pre' or os.environ.get('HCOM_LAUNCHED') == '1':
|
|
3557
|
+
return False
|
|
3558
|
+
|
|
3559
|
+
session_id = hook_data.get('session_id', '')
|
|
3560
|
+
if not session_id: # No session_id = can't identify instance, skip hook
|
|
3561
|
+
return True
|
|
3562
|
+
|
|
3563
|
+
instance_name = get_display_name(session_id, get_config().tag)
|
|
3564
|
+
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
3565
|
+
|
|
3566
|
+
if not instance_file.exists():
|
|
3567
|
+
# Allow UserPromptSubmit if prompt contains hcom command
|
|
3568
|
+
if hook_type == 'userpromptsubmit':
|
|
3569
|
+
user_prompt = hook_data.get('prompt', '')
|
|
3570
|
+
return not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE)
|
|
3571
|
+
return True
|
|
3572
|
+
|
|
3573
|
+
return False
|
|
3574
|
+
|
|
3510
3575
|
def handle_hook(hook_type: str) -> None:
|
|
3511
3576
|
"""Unified hook handler for all HCOM hooks"""
|
|
3512
3577
|
hook_data = json.load(sys.stdin)
|
|
3513
3578
|
|
|
3514
3579
|
if not ensure_hcom_directories():
|
|
3515
3580
|
log_hook_error('handle_hook', Exception('Failed to create directories'))
|
|
3516
|
-
sys.exit(
|
|
3517
|
-
|
|
3518
|
-
session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
|
|
3519
|
-
source_debug = hook_data.get('source', 'NO_SOURCE')
|
|
3520
|
-
|
|
3521
|
-
# Vanilla instance check (not hcom-launched)
|
|
3522
|
-
# Exit early if no instance file exists, except:
|
|
3523
|
-
# - PreToolUse (handles first send opt-in)
|
|
3524
|
-
# - UserPromptSubmit with hcom command (shows preemptive bootstrap)
|
|
3525
|
-
if hook_type != 'pre' and os.environ.get('HCOM_LAUNCHED') != '1':
|
|
3526
|
-
session_id = hook_data.get('session_id', '')
|
|
3527
|
-
if not session_id:
|
|
3528
|
-
sys.exit(EXIT_SUCCESS)
|
|
3529
|
-
|
|
3530
|
-
instance_name = get_display_name(session_id, os.environ.get('HCOM_PREFIX'))
|
|
3531
|
-
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
3532
|
-
|
|
3533
|
-
if not instance_file.exists():
|
|
3534
|
-
# Allow UserPromptSubmit through if prompt contains hcom command
|
|
3535
|
-
if hook_type == 'userpromptsubmit':
|
|
3536
|
-
user_prompt = hook_data.get('prompt', '')
|
|
3537
|
-
if not re.search(r'\bhcom\s+\w+', user_prompt, re.IGNORECASE):
|
|
3538
|
-
sys.exit(EXIT_SUCCESS)
|
|
3539
|
-
# Continue - let handle_userpromptsubmit show bootstrap
|
|
3540
|
-
else:
|
|
3541
|
-
sys.exit(EXIT_SUCCESS)
|
|
3581
|
+
sys.exit(0)
|
|
3542
3582
|
|
|
3543
|
-
#
|
|
3544
|
-
|
|
3583
|
+
# SessionStart is standalone - no instance files
|
|
3584
|
+
if hook_type == 'sessionstart':
|
|
3585
|
+
handle_sessionstart(hook_data)
|
|
3586
|
+
sys.exit(0)
|
|
3545
3587
|
|
|
3546
|
-
#
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
|
|
3550
|
-
sys.exit(EXIT_SUCCESS)
|
|
3588
|
+
# Vanilla instance check - exit early if should skip
|
|
3589
|
+
if should_skip_vanilla_instance(hook_type, hook_data):
|
|
3590
|
+
sys.exit(0)
|
|
3551
3591
|
|
|
3552
|
-
#
|
|
3592
|
+
# Initialize instance context (creates file if needed, reuses existing if session_id matches)
|
|
3593
|
+
instance_name, updates, is_matched_resume = init_hook_context(hook_data, hook_type)
|
|
3594
|
+
|
|
3595
|
+
# Load instance data once (for enabled check and to pass to handlers)
|
|
3596
|
+
instance_data = None
|
|
3553
3597
|
if hook_type != 'pre':
|
|
3554
3598
|
instance_data = load_instance_position(instance_name)
|
|
3555
|
-
|
|
3556
|
-
|
|
3599
|
+
|
|
3600
|
+
# Skip enabled check for UserPromptSubmit when bootstrap needs to be shown
|
|
3601
|
+
# (alias_announced=false means bootstrap hasn't been shown yet)
|
|
3602
|
+
skip_enabled_check = (hook_type == 'userpromptsubmit' and
|
|
3603
|
+
not instance_data.get('alias_announced', False))
|
|
3604
|
+
|
|
3605
|
+
if not skip_enabled_check and not instance_data.get('enabled', False):
|
|
3606
|
+
sys.exit(0)
|
|
3557
3607
|
|
|
3558
3608
|
match hook_type:
|
|
3559
3609
|
case 'pre':
|
|
3560
|
-
handle_pretooluse(hook_data, instance_name
|
|
3610
|
+
handle_pretooluse(hook_data, instance_name)
|
|
3611
|
+
case 'post':
|
|
3612
|
+
handle_posttooluse(hook_data, instance_name)
|
|
3561
3613
|
case 'poll':
|
|
3562
|
-
handle_stop(hook_data, instance_name, updates)
|
|
3614
|
+
handle_stop(hook_data, instance_name, updates, instance_data)
|
|
3563
3615
|
case 'notify':
|
|
3564
|
-
handle_notify(hook_data, instance_name, updates)
|
|
3616
|
+
handle_notify(hook_data, instance_name, updates, instance_data)
|
|
3565
3617
|
case 'userpromptsubmit':
|
|
3566
|
-
handle_userpromptsubmit(hook_data, instance_name, updates,
|
|
3567
|
-
case 'sessionstart':
|
|
3568
|
-
handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
|
|
3618
|
+
handle_userpromptsubmit(hook_data, instance_name, updates, is_matched_resume, instance_data)
|
|
3569
3619
|
case 'sessionend':
|
|
3570
|
-
handle_sessionend(hook_data, instance_name, updates)
|
|
3620
|
+
handle_sessionend(hook_data, instance_name, updates, instance_data)
|
|
3571
3621
|
|
|
3572
|
-
sys.exit(
|
|
3622
|
+
sys.exit(0)
|
|
3573
3623
|
|
|
3574
3624
|
|
|
3575
3625
|
# ==================== Main Entry Point ====================
|
|
3576
3626
|
|
|
3577
|
-
def main(argv=None):
|
|
3627
|
+
def main(argv: list[str] | None = None) -> int | None:
|
|
3578
3628
|
"""Main command dispatcher"""
|
|
3579
3629
|
if argv is None:
|
|
3580
3630
|
argv = sys.argv[1:]
|
|
3581
3631
|
else:
|
|
3582
3632
|
argv = argv[1:] if len(argv) > 0 and argv[0].endswith('hcom.py') else argv
|
|
3583
3633
|
|
|
3584
|
-
#
|
|
3585
|
-
if
|
|
3586
|
-
return cmd_help()
|
|
3587
|
-
|
|
3588
|
-
# Handle hook commands (special case - no parsing needed)
|
|
3589
|
-
if argv and argv[0] in ('poll', 'notify', 'pre', 'sessionstart', 'userpromptsubmit', 'sessionend'):
|
|
3634
|
+
# Hook handlers only (called BY hooks, not users)
|
|
3635
|
+
if argv and argv[0] in ('poll', 'notify', 'pre', 'post', 'sessionstart', 'userpromptsubmit', 'sessionend'):
|
|
3590
3636
|
handle_hook(argv[0])
|
|
3591
3637
|
return 0
|
|
3592
3638
|
|
|
3593
|
-
#
|
|
3594
|
-
if
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
return 1
|
|
3598
|
-
return cmd_send_cli(argv[1])
|
|
3599
|
-
|
|
3600
|
-
# Split on -- separator for forwarding args
|
|
3601
|
-
hcom_args, forwarded = split_forwarded_args(argv)
|
|
3602
|
-
|
|
3603
|
-
# Ensure directories exist for commands that need them
|
|
3604
|
-
if hcom_args and hcom_args[0] not in ('help', '--help', '-h'):
|
|
3605
|
-
if not ensure_hcom_directories():
|
|
3606
|
-
print(format_error("Failed to create HCOM directories"), file=sys.stderr)
|
|
3607
|
-
return 1
|
|
3639
|
+
# Ensure directories exist first (required for version check cache)
|
|
3640
|
+
if not ensure_hcom_directories():
|
|
3641
|
+
print(format_error("Failed to create HCOM directories"), file=sys.stderr)
|
|
3642
|
+
return 1
|
|
3608
3643
|
|
|
3609
|
-
#
|
|
3610
|
-
|
|
3644
|
+
# Check for updates and show message if available (once daily check, persists until upgrade)
|
|
3645
|
+
if msg := get_update_notice():
|
|
3646
|
+
print(msg, file=sys.stderr)
|
|
3611
3647
|
|
|
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
|
|
3648
|
+
# Ensure hooks current (warns but never blocks)
|
|
3649
|
+
ensure_hooks_current()
|
|
3618
3650
|
|
|
3619
|
-
#
|
|
3651
|
+
# Route to commands
|
|
3620
3652
|
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
|
|
3653
|
+
if not argv or argv[0] in ('help', '--help', '-h'):
|
|
3654
|
+
return cmd_help()
|
|
3655
|
+
elif argv[0] == 'send_cli':
|
|
3656
|
+
if len(argv) < 2:
|
|
3657
|
+
print(format_error("Message required"), file=sys.stderr)
|
|
3658
|
+
return 1
|
|
3659
|
+
return send_cli(argv[1])
|
|
3660
|
+
elif argv[0] == 'watch':
|
|
3661
|
+
return cmd_watch(argv[1:])
|
|
3662
|
+
elif argv[0] == 'send':
|
|
3663
|
+
return cmd_send(argv[1:])
|
|
3664
|
+
elif argv[0] == 'stop':
|
|
3665
|
+
return cmd_stop(argv[1:])
|
|
3666
|
+
elif argv[0] == 'start':
|
|
3667
|
+
return cmd_start(argv[1:])
|
|
3668
|
+
elif argv[0] == 'reset':
|
|
3669
|
+
return cmd_reset(argv[1:])
|
|
3670
|
+
elif argv[0].isdigit() or argv[0] == 'claude':
|
|
3671
|
+
# Launch instances: hcom <1-100> [args] or hcom claude [args]
|
|
3672
|
+
return cmd_launch(argv)
|
|
3638
3673
|
else:
|
|
3639
|
-
print(format_error(
|
|
3674
|
+
print(format_error(
|
|
3675
|
+
f"Unknown command: {argv[0]}",
|
|
3676
|
+
"Run 'hcom --help' for usage"
|
|
3677
|
+
), file=sys.stderr)
|
|
3640
3678
|
return 1
|
|
3641
|
-
|
|
3642
3679
|
except CLIError as exc:
|
|
3643
3680
|
print(str(exc), file=sys.stderr)
|
|
3644
3681
|
return 1
|