hcom 0.2.3__py3-none-any.whl → 0.3.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 +672 -806
- {hcom-0.2.3.dist-info → hcom-0.3.0.dist-info}/METADATA +11 -11
- hcom-0.3.0.dist-info/RECORD +7 -0
- hcom-0.2.3.dist-info/RECORD +0 -7
- {hcom-0.2.3.dist-info → hcom-0.3.0.dist-info}/WHEEL +0 -0
- {hcom-0.2.3.dist-info → hcom-0.3.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.2.3.dist-info → hcom-0.3.0.dist-info}/top_level.txt +0 -0
hcom/__main__.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
hcom 0.
|
|
3
|
+
hcom 0.3.0
|
|
4
4
|
CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
8
|
import sys
|
|
9
9
|
import json
|
|
10
|
+
import io
|
|
10
11
|
import tempfile
|
|
11
12
|
import shutil
|
|
12
13
|
import shlex
|
|
@@ -18,6 +19,11 @@ import platform
|
|
|
18
19
|
import random
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from datetime import datetime, timedelta
|
|
22
|
+
from typing import Optional, Any, NamedTuple
|
|
23
|
+
from dataclasses import dataclass, asdict, field
|
|
24
|
+
|
|
25
|
+
if sys.version_info < (3, 10):
|
|
26
|
+
sys.exit("Error: hcom requires Python 3.10 or higher")
|
|
21
27
|
|
|
22
28
|
# ==================== Constants ====================
|
|
23
29
|
|
|
@@ -30,7 +36,7 @@ def is_wsl():
|
|
|
30
36
|
try:
|
|
31
37
|
with open('/proc/version', 'r') as f:
|
|
32
38
|
return 'microsoft' in f.read().lower()
|
|
33
|
-
except:
|
|
39
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
34
40
|
return False
|
|
35
41
|
|
|
36
42
|
def is_termux():
|
|
@@ -38,7 +44,7 @@ def is_termux():
|
|
|
38
44
|
return (
|
|
39
45
|
'TERMUX_VERSION' in os.environ or # Primary: Works all versions
|
|
40
46
|
'TERMUX__ROOTFS' in os.environ or # Modern: v0.119.0+
|
|
41
|
-
|
|
47
|
+
Path('/data/data/com.termux').exists() or # Fallback: Path check
|
|
42
48
|
'com.termux' in os.environ.get('PREFIX', '') # Fallback: PREFIX check
|
|
43
49
|
)
|
|
44
50
|
|
|
@@ -46,25 +52,21 @@ HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
|
|
|
46
52
|
HCOM_ACTIVE_VALUE = '1'
|
|
47
53
|
|
|
48
54
|
EXIT_SUCCESS = 0
|
|
49
|
-
EXIT_ERROR = 1
|
|
50
55
|
EXIT_BLOCK = 2
|
|
51
56
|
|
|
52
|
-
HOOK_DECISION_BLOCK = 'block'
|
|
53
|
-
|
|
54
57
|
ERROR_ACCESS_DENIED = 5 # Windows - Process exists but no permission
|
|
55
58
|
ERROR_INVALID_PARAMETER = 87 # Windows - Invalid PID or parameters
|
|
56
|
-
ERROR_ALREADY_EXISTS = 183 # Windows - For file/mutex creation, not process checks
|
|
57
59
|
|
|
58
60
|
# Windows API constants
|
|
59
|
-
DETACHED_PROCESS = 0x00000008 # CreateProcess flag for no console window
|
|
60
61
|
CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
|
|
61
62
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 # Vista+ minimal access rights
|
|
62
|
-
PROCESS_QUERY_INFORMATION = 0x0400 # Pre-Vista process access rights TODO: is this a joke? why am i supporting pre vista? who the fuck is running claude code on vista let alone pre?!
|
|
63
|
+
PROCESS_QUERY_INFORMATION = 0x0400 # Pre-Vista process access rights TODO: is this a joke? why am i supporting pre vista? who the fuck is running claude code on vista let alone pre?! great to keep this comment here! and i will be leaving it here!
|
|
63
64
|
|
|
64
65
|
# Timing constants
|
|
65
66
|
FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
|
|
66
|
-
STOP_HOOK_POLL_INTERVAL = 0.
|
|
67
|
+
STOP_HOOK_POLL_INTERVAL = 0.1 # 100ms between stop hook polls
|
|
67
68
|
KILL_CHECK_INTERVAL = 0.1 # 100ms between process termination checks
|
|
69
|
+
MERGE_ACTIVITY_THRESHOLD = 10 # Seconds of inactivity before allowing instance merge
|
|
68
70
|
|
|
69
71
|
# Windows kernel32 cache
|
|
70
72
|
_windows_kernel32_cache = None
|
|
@@ -77,7 +79,7 @@ def get_windows_kernel32():
|
|
|
77
79
|
if _windows_kernel32_cache is None and IS_WINDOWS:
|
|
78
80
|
import ctypes
|
|
79
81
|
import ctypes.wintypes
|
|
80
|
-
kernel32 = ctypes.windll.kernel32
|
|
82
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
81
83
|
|
|
82
84
|
# Set proper ctypes function signatures to avoid ERROR_INVALID_PARAMETER
|
|
83
85
|
kernel32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
|
|
@@ -108,25 +110,38 @@ BG_GREEN = "\033[42m"
|
|
|
108
110
|
BG_CYAN = "\033[46m"
|
|
109
111
|
BG_YELLOW = "\033[43m"
|
|
110
112
|
BG_RED = "\033[41m"
|
|
113
|
+
BG_GRAY = "\033[100m"
|
|
111
114
|
|
|
112
115
|
STATUS_MAP = {
|
|
113
|
-
"thinking": (BG_CYAN, "◉"),
|
|
114
|
-
"responding": (BG_GREEN, "▷"),
|
|
115
|
-
"executing": (BG_GREEN, "▶"),
|
|
116
116
|
"waiting": (BG_BLUE, "◉"),
|
|
117
|
+
"delivered": (BG_CYAN, "▷"),
|
|
118
|
+
"active": (BG_GREEN, "▶"),
|
|
117
119
|
"blocked": (BG_YELLOW, "■"),
|
|
118
|
-
"inactive": (BG_RED, "○")
|
|
120
|
+
"inactive": (BG_RED, "○"),
|
|
121
|
+
"unknown": (BG_GRAY, "○")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Map status events to (display_category, description_template)
|
|
125
|
+
STATUS_INFO = {
|
|
126
|
+
'session_start': ('active', 'started'),
|
|
127
|
+
'tool_pending': ('active', '{} executing'),
|
|
128
|
+
'waiting': ('waiting', 'idle'),
|
|
129
|
+
'message_delivered': ('delivered', 'msg from {}'),
|
|
130
|
+
'stop_exit': ('inactive', 'stopped'),
|
|
131
|
+
'timeout': ('inactive', 'timeout'),
|
|
132
|
+
'killed': ('inactive', 'killed'),
|
|
133
|
+
'blocked': ('blocked', '{} blocked'),
|
|
134
|
+
'unknown': ('unknown', 'unknown'),
|
|
119
135
|
}
|
|
120
136
|
|
|
121
137
|
# ==================== Windows/WSL Console Unicode ====================
|
|
122
|
-
import io
|
|
123
138
|
|
|
124
139
|
# Apply UTF-8 encoding for Windows and WSL
|
|
125
140
|
if IS_WINDOWS or is_wsl():
|
|
126
141
|
try:
|
|
127
142
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
128
143
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
|
129
|
-
except:
|
|
144
|
+
except (AttributeError, OSError):
|
|
130
145
|
pass # Fallback if stream redirection fails
|
|
131
146
|
|
|
132
147
|
# ==================== Error Handling Strategy ====================
|
|
@@ -135,39 +150,51 @@ if IS_WINDOWS or is_wsl():
|
|
|
135
150
|
# Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
|
|
136
151
|
# Pattern: Try/except/return False in hooks, raise in CLI operations.
|
|
137
152
|
|
|
153
|
+
def log_hook_error(hook_name: str, error: Exception | None = None):
|
|
154
|
+
"""Log hook exceptions or just general logging to ~/.hcom/scripts/hooks.log for debugging"""
|
|
155
|
+
import traceback
|
|
156
|
+
try:
|
|
157
|
+
log_file = hcom_path(SCRIPTS_DIR) / "hooks.log"
|
|
158
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
timestamp = datetime.now().isoformat()
|
|
160
|
+
if error and isinstance(error, Exception):
|
|
161
|
+
tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
162
|
+
with open(log_file, 'a') as f:
|
|
163
|
+
f.write(f"{timestamp}|{hook_name}|{type(error).__name__}: {error}\n{tb}\n")
|
|
164
|
+
else:
|
|
165
|
+
with open(log_file, 'a') as f:
|
|
166
|
+
f.write(f"{timestamp}|{hook_name}|{error or 'checkpoint'}\n")
|
|
167
|
+
except (OSError, PermissionError):
|
|
168
|
+
pass # Silent failure in error logging
|
|
169
|
+
|
|
138
170
|
# ==================== Config Defaults ====================
|
|
139
171
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
# Type definition for configuration
|
|
173
|
+
@dataclass
|
|
174
|
+
class HcomConfig:
|
|
175
|
+
terminal_command: str | None = None
|
|
176
|
+
terminal_mode: str = "new_window"
|
|
177
|
+
initial_prompt: str = "Say hi in chat"
|
|
178
|
+
sender_name: str = "bigboss"
|
|
179
|
+
sender_emoji: str = "🐳"
|
|
180
|
+
cli_hints: str = ""
|
|
181
|
+
wait_timeout: int = 1800 # 30mins
|
|
182
|
+
max_message_size: int = 1048576 # 1MB
|
|
183
|
+
max_messages_per_delivery: int = 50
|
|
184
|
+
first_use_text: str = "Essential, concise messages only, say hi in hcom chat now"
|
|
185
|
+
instance_hints: str = ""
|
|
186
|
+
env_overrides: dict = field(default_factory=dict)
|
|
187
|
+
auto_watch: bool = True # Auto-launch watch dashboard after open
|
|
188
|
+
|
|
189
|
+
DEFAULT_CONFIG = HcomConfig()
|
|
155
190
|
|
|
156
191
|
_config = None
|
|
157
192
|
|
|
193
|
+
# Generate env var mappings from dataclass fields (except env_overrides)
|
|
158
194
|
HOOK_SETTINGS = {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
'first_use_text': 'HCOM_FIRST_USE_TEXT',
|
|
163
|
-
'instance_hints': 'HCOM_INSTANCE_HINTS',
|
|
164
|
-
'sender_name': 'HCOM_SENDER_NAME',
|
|
165
|
-
'sender_emoji': 'HCOM_SENDER_EMOJI',
|
|
166
|
-
'cli_hints': 'HCOM_CLI_HINTS',
|
|
167
|
-
'terminal_mode': 'HCOM_TERMINAL_MODE',
|
|
168
|
-
'terminal_command': 'HCOM_TERMINAL_COMMAND',
|
|
169
|
-
'initial_prompt': 'HCOM_INITIAL_PROMPT',
|
|
170
|
-
'auto_watch': 'HCOM_AUTO_WATCH'
|
|
195
|
+
field: f"HCOM_{field.upper()}"
|
|
196
|
+
for field in DEFAULT_CONFIG.__dataclass_fields__
|
|
197
|
+
if field != 'env_overrides'
|
|
171
198
|
}
|
|
172
199
|
|
|
173
200
|
# Path constants
|
|
@@ -180,7 +207,7 @@ ARCHIVE_DIR = "archive"
|
|
|
180
207
|
|
|
181
208
|
# ==================== File System Utilities ====================
|
|
182
209
|
|
|
183
|
-
def hcom_path(*parts, ensure_parent=False):
|
|
210
|
+
def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
|
|
184
211
|
"""Build path under ~/.hcom"""
|
|
185
212
|
path = Path.home() / ".hcom"
|
|
186
213
|
if parts:
|
|
@@ -189,8 +216,8 @@ def hcom_path(*parts, ensure_parent=False):
|
|
|
189
216
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
217
|
return path
|
|
191
218
|
|
|
192
|
-
def atomic_write(filepath, content):
|
|
193
|
-
"""Write content to file atomically to prevent corruption (now with NEW and IMPROVED (wow!) Windows retry logic (cool
|
|
219
|
+
def atomic_write(filepath: str | Path, content: str) -> bool:
|
|
220
|
+
"""Write content to file atomically to prevent corruption (now with NEW and IMPROVED (wow!) Windows retry logic (cool!!!)). Returns True on success, False on failure."""
|
|
194
221
|
filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
|
|
195
222
|
|
|
196
223
|
for attempt in range(3):
|
|
@@ -208,18 +235,20 @@ def atomic_write(filepath, content):
|
|
|
208
235
|
continue
|
|
209
236
|
else:
|
|
210
237
|
try: # Clean up temp file on final failure
|
|
211
|
-
|
|
212
|
-
except:
|
|
238
|
+
Path(tmp.name).unlink()
|
|
239
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
213
240
|
pass
|
|
214
241
|
return False
|
|
215
242
|
except Exception:
|
|
216
243
|
try: # Clean up temp file on any other error
|
|
217
244
|
os.unlink(tmp.name)
|
|
218
|
-
except:
|
|
245
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
219
246
|
pass
|
|
220
247
|
return False
|
|
221
248
|
|
|
222
|
-
|
|
249
|
+
return False # All attempts exhausted
|
|
250
|
+
|
|
251
|
+
def read_file_with_retry(filepath: str | Path, read_func, default: Any = None, max_retries: int = 3) -> Any:
|
|
223
252
|
"""Read file with retry logic for Windows file locking"""
|
|
224
253
|
if not Path(filepath).exists():
|
|
225
254
|
return default
|
|
@@ -228,7 +257,7 @@ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
|
|
|
228
257
|
try:
|
|
229
258
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
230
259
|
return read_func(f)
|
|
231
|
-
except PermissionError
|
|
260
|
+
except PermissionError:
|
|
232
261
|
# Only retry on Windows (file locking issue)
|
|
233
262
|
if IS_WINDOWS and attempt < max_retries - 1:
|
|
234
263
|
time.sleep(FILE_RETRY_DELAY)
|
|
@@ -242,37 +271,18 @@ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
|
|
|
242
271
|
|
|
243
272
|
return default
|
|
244
273
|
|
|
245
|
-
def get_instance_file(instance_name):
|
|
274
|
+
def get_instance_file(instance_name: str) -> Path:
|
|
246
275
|
"""Get path to instance's position file with path traversal protection"""
|
|
247
276
|
# Sanitize instance name to prevent directory traversal
|
|
248
277
|
if not instance_name:
|
|
249
278
|
instance_name = "unknown"
|
|
250
279
|
safe_name = instance_name.replace('..', '').replace('/', '-').replace('\\', '-').replace(os.sep, '-')
|
|
251
280
|
if not safe_name:
|
|
252
|
-
safe_name = "
|
|
281
|
+
safe_name = "unknown"
|
|
253
282
|
|
|
254
283
|
return hcom_path(INSTANCES_DIR, f"{safe_name}.json")
|
|
255
284
|
|
|
256
|
-
def
|
|
257
|
-
"""One-time migration from v0.2.0 format (remove in v0.3.0)"""
|
|
258
|
-
needs_save = False
|
|
259
|
-
|
|
260
|
-
# Convert single session_id to session_ids array
|
|
261
|
-
if 'session_ids' not in data and 'session_id' in data and data['session_id']:
|
|
262
|
-
data['session_ids'] = [data['session_id']]
|
|
263
|
-
needs_save = True
|
|
264
|
-
|
|
265
|
-
# Remove conversation_uuid - no longer used anywhere
|
|
266
|
-
if 'conversation_uuid' in data:
|
|
267
|
-
del data['conversation_uuid']
|
|
268
|
-
needs_save = True
|
|
269
|
-
|
|
270
|
-
if needs_save:
|
|
271
|
-
save_instance_position(instance_name, data)
|
|
272
|
-
|
|
273
|
-
return data
|
|
274
|
-
|
|
275
|
-
def load_instance_position(instance_name):
|
|
285
|
+
def load_instance_position(instance_name: str) -> dict[str, Any]:
|
|
276
286
|
"""Load position data for a single instance"""
|
|
277
287
|
instance_file = get_instance_file(instance_name)
|
|
278
288
|
|
|
@@ -282,21 +292,17 @@ def load_instance_position(instance_name):
|
|
|
282
292
|
default={}
|
|
283
293
|
)
|
|
284
294
|
|
|
285
|
-
# Apply migration if needed
|
|
286
|
-
if data:
|
|
287
|
-
data = migrate_instance_data_v020(data, instance_name)
|
|
288
|
-
|
|
289
295
|
return data
|
|
290
296
|
|
|
291
|
-
def save_instance_position(instance_name, data):
|
|
297
|
+
def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
|
|
292
298
|
"""Save position data for a single instance. Returns True on success, False on failure."""
|
|
293
299
|
try:
|
|
294
300
|
instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json", ensure_parent=True)
|
|
295
301
|
return atomic_write(instance_file, json.dumps(data, indent=2))
|
|
296
|
-
except:
|
|
302
|
+
except (OSError, PermissionError, ValueError):
|
|
297
303
|
return False
|
|
298
304
|
|
|
299
|
-
def load_all_positions():
|
|
305
|
+
def load_all_positions() -> dict[str, dict[str, Any]]:
|
|
300
306
|
"""Load positions from all instance files"""
|
|
301
307
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
302
308
|
if not instances_dir.exists():
|
|
@@ -314,31 +320,18 @@ def load_all_positions():
|
|
|
314
320
|
positions[instance_name] = data
|
|
315
321
|
return positions
|
|
316
322
|
|
|
317
|
-
def clear_all_positions():
|
|
318
|
-
"""Clear all instance position files"""
|
|
323
|
+
def clear_all_positions() -> None:
|
|
324
|
+
"""Clear all instance position files and related mapping files"""
|
|
319
325
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
320
326
|
if instances_dir.exists():
|
|
321
327
|
for f in instances_dir.glob('*.json'):
|
|
322
328
|
f.unlink()
|
|
329
|
+
# Clean up orphaned mapping files
|
|
330
|
+
for f in instances_dir.glob('.launch_map_*'):
|
|
331
|
+
f.unlink(missing_ok=True)
|
|
323
332
|
else:
|
|
324
333
|
instances_dir.mkdir(exist_ok=True)
|
|
325
334
|
|
|
326
|
-
# ==================== Hook Utilities ====================
|
|
327
|
-
|
|
328
|
-
HCOM_FORMAT_INSTRUCTIONS = """\n\nREQUIRED: Display HCOM 'reason' content using this format with markdown:
|
|
329
|
-
═════════ HCOM ═════════
|
|
330
|
-
***[✓ Sent] [new message] and/or [all other info in square brackets]***
|
|
331
|
-
**name → name:** *message content*
|
|
332
|
-
════════════════════════
|
|
333
|
-
Always show exact full content of every single message without saying why you are displaying it. This format is mandatory for system functionality"""
|
|
334
|
-
|
|
335
|
-
def emit_hook_response(reason, decision=HOOK_DECISION_BLOCK, exit_code=EXIT_BLOCK):
|
|
336
|
-
"""Emit standardized hook JSON response"""
|
|
337
|
-
output = {"decision": decision, "reason": reason} if decision else {"reason": reason}
|
|
338
|
-
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
339
|
-
sys.exit(exit_code)
|
|
340
|
-
|
|
341
|
-
|
|
342
335
|
# ==================== Configuration System ====================
|
|
343
336
|
|
|
344
337
|
def get_cached_config():
|
|
@@ -348,33 +341,31 @@ def get_cached_config():
|
|
|
348
341
|
_config = _load_config_from_file()
|
|
349
342
|
return _config
|
|
350
343
|
|
|
351
|
-
def _load_config_from_file():
|
|
352
|
-
"""
|
|
353
|
-
import copy
|
|
344
|
+
def _load_config_from_file() -> dict:
|
|
345
|
+
"""Load configuration from ~/.hcom/config.json"""
|
|
354
346
|
config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
|
|
355
347
|
|
|
356
|
-
config
|
|
348
|
+
# Start with default config as dict
|
|
349
|
+
config_dict = asdict(DEFAULT_CONFIG)
|
|
357
350
|
|
|
358
351
|
try:
|
|
359
|
-
user_config
|
|
352
|
+
if user_config := read_file_with_retry(
|
|
360
353
|
config_path,
|
|
361
354
|
lambda f: json.load(f),
|
|
362
355
|
default=None
|
|
363
|
-
)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if key == 'env_overrides':
|
|
367
|
-
config['env_overrides'].update(value)
|
|
368
|
-
else:
|
|
369
|
-
config[key] = value
|
|
356
|
+
):
|
|
357
|
+
# Merge user config into default config
|
|
358
|
+
config_dict.update(user_config)
|
|
370
359
|
elif not config_path.exists():
|
|
371
|
-
|
|
360
|
+
# Write default config if file doesn't exist
|
|
361
|
+
atomic_write(config_path, json.dumps(config_dict, indent=2))
|
|
372
362
|
except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
|
|
373
363
|
print("Warning: Cannot read config file, using defaults", file=sys.stderr)
|
|
364
|
+
# config_dict already has defaults
|
|
374
365
|
|
|
375
|
-
return
|
|
366
|
+
return config_dict
|
|
376
367
|
|
|
377
|
-
def get_config_value(key, default=None):
|
|
368
|
+
def get_config_value(key: str, default: Any = None) -> Any:
|
|
378
369
|
"""Get config value with proper precedence:
|
|
379
370
|
1. Environment variable (if in HOOK_SETTINGS)
|
|
380
371
|
2. Config file
|
|
@@ -382,8 +373,7 @@ def get_config_value(key, default=None):
|
|
|
382
373
|
"""
|
|
383
374
|
if key in HOOK_SETTINGS:
|
|
384
375
|
env_var = HOOK_SETTINGS[key]
|
|
385
|
-
env_value
|
|
386
|
-
if env_value is not None:
|
|
376
|
+
if (env_value := os.environ.get(env_var)) is not None:
|
|
387
377
|
# Type conversion based on key
|
|
388
378
|
if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
|
|
389
379
|
try:
|
|
@@ -407,7 +397,7 @@ def get_hook_command():
|
|
|
407
397
|
Both approaches exit silently (code 0) when not launched via 'hcom open'.
|
|
408
398
|
"""
|
|
409
399
|
python_path = sys.executable
|
|
410
|
-
script_path =
|
|
400
|
+
script_path = str(Path(__file__).resolve())
|
|
411
401
|
|
|
412
402
|
if IS_WINDOWS:
|
|
413
403
|
# Windows cmd.exe syntax - no parentheses so arguments append correctly
|
|
@@ -423,6 +413,15 @@ def get_hook_command():
|
|
|
423
413
|
# Unix clean paths: use environment variable
|
|
424
414
|
return '${HCOM:-true}', {}
|
|
425
415
|
|
|
416
|
+
def build_send_command(example_msg: str = '') -> str:
|
|
417
|
+
"""Build send command string - checks if $HCOM exists, falls back to full path"""
|
|
418
|
+
msg = f" '{example_msg}'" if example_msg else ''
|
|
419
|
+
if os.environ.get('HCOM'):
|
|
420
|
+
return f'eval "$HCOM send{msg}"'
|
|
421
|
+
python_path = shlex.quote(sys.executable)
|
|
422
|
+
script_path = shlex.quote(str(Path(__file__).resolve()))
|
|
423
|
+
return f'{python_path} {script_path} send{msg}'
|
|
424
|
+
|
|
426
425
|
def build_claude_env():
|
|
427
426
|
"""Build environment variables for Claude instances"""
|
|
428
427
|
env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
|
|
@@ -444,7 +443,7 @@ def build_claude_env():
|
|
|
444
443
|
|
|
445
444
|
# Set HCOM only for clean paths (spaces handled differently)
|
|
446
445
|
python_path = sys.executable
|
|
447
|
-
script_path =
|
|
446
|
+
script_path = str(Path(__file__).resolve())
|
|
448
447
|
if ' ' not in python_path and ' ' not in script_path:
|
|
449
448
|
env['HCOM'] = f'{python_path} {script_path}'
|
|
450
449
|
|
|
@@ -452,8 +451,8 @@ def build_claude_env():
|
|
|
452
451
|
|
|
453
452
|
# ==================== Message System ====================
|
|
454
453
|
|
|
455
|
-
def validate_message(message):
|
|
456
|
-
"""Validate message size and content"""
|
|
454
|
+
def validate_message(message: str) -> Optional[str]:
|
|
455
|
+
"""Validate message size and content. Returns error message or None if valid."""
|
|
457
456
|
if not message or not message.strip():
|
|
458
457
|
return format_error("Message required")
|
|
459
458
|
|
|
@@ -467,7 +466,7 @@ def validate_message(message):
|
|
|
467
466
|
|
|
468
467
|
return None
|
|
469
468
|
|
|
470
|
-
def send_message(from_instance, message):
|
|
469
|
+
def send_message(from_instance: str, message: str) -> bool:
|
|
471
470
|
"""Send a message to the log"""
|
|
472
471
|
try:
|
|
473
472
|
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
@@ -486,7 +485,7 @@ def send_message(from_instance, message):
|
|
|
486
485
|
except Exception:
|
|
487
486
|
return False
|
|
488
487
|
|
|
489
|
-
def should_deliver_message(msg, instance_name, all_instance_names=None):
|
|
488
|
+
def should_deliver_message(msg: dict[str, str], instance_name: str, all_instance_names: Optional[list[str]] = None) -> bool:
|
|
490
489
|
"""Check if message should be delivered based on @-mentions"""
|
|
491
490
|
text = msg['message']
|
|
492
491
|
|
|
@@ -522,7 +521,7 @@ def should_deliver_message(msg, instance_name, all_instance_names=None):
|
|
|
522
521
|
|
|
523
522
|
# ==================== Parsing & Utilities ====================
|
|
524
523
|
|
|
525
|
-
def parse_open_args(args):
|
|
524
|
+
def parse_open_args(args: list[str]) -> tuple[list[str], Optional[str], list[str], bool]:
|
|
526
525
|
"""Parse arguments for open command
|
|
527
526
|
|
|
528
527
|
Returns:
|
|
@@ -577,7 +576,7 @@ def parse_open_args(args):
|
|
|
577
576
|
|
|
578
577
|
return instances, prefix, claude_args, background
|
|
579
578
|
|
|
580
|
-
def extract_agent_config(content):
|
|
579
|
+
def extract_agent_config(content: str) -> dict[str, str]:
|
|
581
580
|
"""Extract configuration from agent YAML frontmatter"""
|
|
582
581
|
if not content.startswith('---'):
|
|
583
582
|
return {}
|
|
@@ -591,22 +590,20 @@ def extract_agent_config(content):
|
|
|
591
590
|
config = {}
|
|
592
591
|
|
|
593
592
|
# Extract model field
|
|
594
|
-
model_match
|
|
595
|
-
if model_match:
|
|
593
|
+
if model_match := re.search(r'^model:\s*(.+)$', yaml_section, re.MULTILINE):
|
|
596
594
|
value = model_match.group(1).strip()
|
|
597
595
|
if value and value.lower() != 'inherit':
|
|
598
596
|
config['model'] = value
|
|
599
|
-
|
|
597
|
+
|
|
600
598
|
# Extract tools field
|
|
601
|
-
tools_match
|
|
602
|
-
if tools_match:
|
|
599
|
+
if tools_match := re.search(r'^tools:\s*(.+)$', yaml_section, re.MULTILINE):
|
|
603
600
|
value = tools_match.group(1).strip()
|
|
604
601
|
if value:
|
|
605
602
|
config['tools'] = value.replace(', ', ',')
|
|
606
603
|
|
|
607
604
|
return config
|
|
608
605
|
|
|
609
|
-
def resolve_agent(name):
|
|
606
|
+
def resolve_agent(name: str) -> tuple[str, dict[str, str]]:
|
|
610
607
|
"""Resolve agent file by name with validation.
|
|
611
608
|
|
|
612
609
|
Looks for agent files in:
|
|
@@ -675,7 +672,7 @@ def resolve_agent(name):
|
|
|
675
672
|
'Check available agents or create the agent file'
|
|
676
673
|
))
|
|
677
674
|
|
|
678
|
-
def strip_frontmatter(content):
|
|
675
|
+
def strip_frontmatter(content: str) -> str:
|
|
679
676
|
"""Strip YAML frontmatter from agent file"""
|
|
680
677
|
if content.startswith('---'):
|
|
681
678
|
# Find the closing --- on its own line
|
|
@@ -685,7 +682,7 @@ def strip_frontmatter(content):
|
|
|
685
682
|
return '\n'.join(lines[i+1:]).strip()
|
|
686
683
|
return content
|
|
687
684
|
|
|
688
|
-
def get_display_name(session_id, prefix=None):
|
|
685
|
+
def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) -> str:
|
|
689
686
|
"""Get display name for instance using session_id"""
|
|
690
687
|
syls = ['ka', 'ko', 'ma', 'mo', 'na', 'no', 'ra', 'ro', 'sa', 'so', 'ta', 'to', 'va', 'vo', 'za', 'zo', 'be', 'de', 'fe', 'ge', 'le', 'me', 'ne', 're', 'se', 'te', 've', 'we', 'hi']
|
|
691
688
|
# Phonetic letters (5 per syllable, matches syls order)
|
|
@@ -707,24 +704,27 @@ def get_display_name(session_id, prefix=None):
|
|
|
707
704
|
hex_char = session_id[0] if session_id else 'x'
|
|
708
705
|
base_name = f"{dir_char}{syllable}{letter}{hex_char}"
|
|
709
706
|
|
|
710
|
-
# Collision detection: if taken by
|
|
707
|
+
# Collision detection: if taken by different session_id, use more chars
|
|
711
708
|
instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
|
|
712
709
|
if instance_file.exists():
|
|
713
710
|
try:
|
|
714
711
|
with open(instance_file, 'r', encoding='utf-8') as f:
|
|
715
712
|
data = json.load(f)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
713
|
+
|
|
714
|
+
their_session_id = data.get('session_id', '')
|
|
715
|
+
|
|
716
|
+
# Deterministic check: different session_id = collision
|
|
717
|
+
if their_session_id and their_session_id != session_id:
|
|
720
718
|
# Use first 4 chars of session_id for collision resolution
|
|
721
719
|
base_name = f"{dir_char}{session_id[0:4]}"
|
|
722
|
-
|
|
723
|
-
|
|
720
|
+
# If same session_id, it's our file - reuse the name (no collision)
|
|
721
|
+
# If no session_id in file, assume it's stale/malformed - use base name
|
|
722
|
+
|
|
723
|
+
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
|
724
|
+
pass # Malformed file - assume stale, use base name
|
|
724
725
|
else:
|
|
725
|
-
#
|
|
726
|
-
|
|
727
|
-
base_name = f"{dir_char}{pid_suffix}claude"
|
|
726
|
+
# session_id is required - fail gracefully
|
|
727
|
+
raise ValueError("session_id required for instance naming")
|
|
728
728
|
|
|
729
729
|
if prefix:
|
|
730
730
|
return f"{prefix}-{base_name}"
|
|
@@ -741,31 +741,27 @@ def _remove_hcom_hooks_from_settings(settings):
|
|
|
741
741
|
import copy
|
|
742
742
|
|
|
743
743
|
# Patterns to match any hcom hook command
|
|
744
|
-
# -
|
|
745
|
-
# - ${HCOM
|
|
744
|
+
# Modern hooks (patterns 1-2, 7): Match all hook types via env var or wrapper
|
|
745
|
+
# - ${HCOM:-true} sessionstart/pre/stop/notify/userpromptsubmit
|
|
746
746
|
# - [ "${HCOM_ACTIVE}" = "1" ] && ... hcom.py ... || true
|
|
747
|
-
# - hcom post/stop/notify
|
|
748
|
-
# - uvx hcom post/stop/notify
|
|
749
|
-
# - /path/to/hcom.py post/stop/notify
|
|
750
747
|
# - sh -c "[ ... ] && ... hcom ..."
|
|
751
|
-
#
|
|
752
|
-
# -
|
|
753
|
-
#
|
|
754
|
-
#
|
|
755
|
-
# The (post|stop|notify) patterns (3-6) are for older direct command formats that didn't
|
|
756
|
-
# include pre/sessionstart hooks.
|
|
748
|
+
# Legacy hooks (patterns 3-6): Direct command invocation
|
|
749
|
+
# - hcom pre/post/stop/notify/sessionstart/userpromptsubmit
|
|
750
|
+
# - uvx hcom pre/post/stop/notify/sessionstart/userpromptsubmit
|
|
751
|
+
# - /path/to/hcom.py pre/post/stop/notify/sessionstart/userpromptsubmit
|
|
757
752
|
hcom_patterns = [
|
|
758
753
|
r'\$\{?HCOM', # Environment variable (${HCOM:-true}) - all hook types
|
|
759
754
|
r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with full path - all hook types
|
|
760
|
-
r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command
|
|
761
|
-
r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command
|
|
762
|
-
r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote
|
|
763
|
-
r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b(?=\s|$)', # Quoted path
|
|
755
|
+
r'\bhcom\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # Direct hcom command
|
|
756
|
+
r'\buvx\s+hcom\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # uvx hcom command
|
|
757
|
+
r'hcom\.py["\']?\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b', # hcom.py with optional quote
|
|
758
|
+
r'["\'][^"\']*hcom\.py["\']?\s+(pre|post|stop|notify|sessionstart|userpromptsubmit)\b(?=\s|$)', # Quoted path
|
|
764
759
|
r'sh\s+-c.*hcom', # Shell wrapper with hcom
|
|
765
760
|
]
|
|
766
761
|
compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
|
|
767
|
-
|
|
768
|
-
|
|
762
|
+
|
|
763
|
+
# Check all hook types including PostToolUse for backward compatibility cleanup
|
|
764
|
+
for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
|
|
769
765
|
if event not in settings['hooks']:
|
|
770
766
|
continue
|
|
771
767
|
|
|
@@ -811,7 +807,7 @@ def build_env_string(env_vars, format_type="bash"):
|
|
|
811
807
|
return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
|
|
812
808
|
|
|
813
809
|
|
|
814
|
-
def format_error(message, suggestion=None):
|
|
810
|
+
def format_error(message: str, suggestion: Optional[str] = None) -> str:
|
|
815
811
|
"""Format error message consistently"""
|
|
816
812
|
base = f"Error: {message}"
|
|
817
813
|
if suggestion:
|
|
@@ -826,7 +822,7 @@ def has_claude_arg(claude_args, arg_names, arg_prefixes):
|
|
|
826
822
|
for arg in claude_args
|
|
827
823
|
)
|
|
828
824
|
|
|
829
|
-
def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat", model=None, tools=None):
|
|
825
|
+
def build_claude_command(agent_content: Optional[str] = None, claude_args: Optional[list[str]] = None, initial_prompt: str = "Say hi in chat", model: Optional[str] = None, tools: Optional[str] = None) -> tuple[str, Optional[str]]:
|
|
830
826
|
"""Build Claude command with proper argument handling
|
|
831
827
|
Returns tuple: (command_string, temp_file_path_or_none)
|
|
832
828
|
For agent content, writes to temp file and uses cat to read it.
|
|
@@ -903,7 +899,7 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
|
|
|
903
899
|
paths_to_add = []
|
|
904
900
|
for p in [node_path, claude_path]:
|
|
905
901
|
if p:
|
|
906
|
-
dir_path =
|
|
902
|
+
dir_path = str(Path(p).resolve().parent)
|
|
907
903
|
if dir_path not in paths_to_add:
|
|
908
904
|
paths_to_add.append(dir_path)
|
|
909
905
|
|
|
@@ -960,17 +956,17 @@ def find_bash_on_windows():
|
|
|
960
956
|
os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
|
|
961
957
|
if base:
|
|
962
958
|
candidates.extend([
|
|
963
|
-
|
|
964
|
-
|
|
959
|
+
str(Path(base) / 'Git' / 'usr' / 'bin' / 'bash.exe'), # usr/bin is more common
|
|
960
|
+
str(Path(base) / 'Git' / 'bin' / 'bash.exe')
|
|
965
961
|
])
|
|
966
962
|
|
|
967
963
|
# 2. Portable Git installation
|
|
968
964
|
local_appdata = os.environ.get('LOCALAPPDATA', '')
|
|
969
965
|
if local_appdata:
|
|
970
|
-
git_portable =
|
|
966
|
+
git_portable = Path(local_appdata) / 'Programs' / 'Git'
|
|
971
967
|
candidates.extend([
|
|
972
|
-
|
|
973
|
-
|
|
968
|
+
str(git_portable / 'usr' / 'bin' / 'bash.exe'),
|
|
969
|
+
str(git_portable / 'bin' / 'bash.exe')
|
|
974
970
|
])
|
|
975
971
|
|
|
976
972
|
# 3. PATH bash (if not WSL's launcher)
|
|
@@ -988,7 +984,7 @@ def find_bash_on_windows():
|
|
|
988
984
|
|
|
989
985
|
# Find first existing bash
|
|
990
986
|
for bash in candidates:
|
|
991
|
-
if bash and
|
|
987
|
+
if bash and Path(bash).exists():
|
|
992
988
|
return bash
|
|
993
989
|
|
|
994
990
|
return None
|
|
@@ -1029,20 +1025,23 @@ def get_linux_terminal_argv():
|
|
|
1029
1025
|
|
|
1030
1026
|
def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
|
|
1031
1027
|
"""Create hidden Windows process without console window."""
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1028
|
+
if IS_WINDOWS:
|
|
1029
|
+
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
|
1030
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
|
|
1031
|
+
startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined]
|
|
1032
|
+
|
|
1033
|
+
return subprocess.Popen(
|
|
1034
|
+
argv,
|
|
1035
|
+
env=env,
|
|
1036
|
+
cwd=cwd,
|
|
1037
|
+
stdin=subprocess.DEVNULL,
|
|
1038
|
+
stdout=stdout,
|
|
1039
|
+
stderr=subprocess.STDOUT,
|
|
1040
|
+
startupinfo=startupinfo,
|
|
1041
|
+
creationflags=CREATE_NO_WINDOW
|
|
1042
|
+
)
|
|
1043
|
+
else:
|
|
1044
|
+
raise RuntimeError("windows_hidden_popen called on non-Windows platform")
|
|
1046
1045
|
|
|
1047
1046
|
# Platform dispatch map
|
|
1048
1047
|
PLATFORM_TERMINAL_GETTERS = {
|
|
@@ -1162,7 +1161,7 @@ def launch_terminal(command, env, cwd=None, background=False):
|
|
|
1162
1161
|
script_content = f.read()
|
|
1163
1162
|
print(f"# Script: {script_file}")
|
|
1164
1163
|
print(script_content)
|
|
1165
|
-
|
|
1164
|
+
Path(script_file).unlink() # Clean up immediately
|
|
1166
1165
|
return True
|
|
1167
1166
|
except Exception as e:
|
|
1168
1167
|
print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
|
|
@@ -1276,11 +1275,12 @@ def setup_hooks():
|
|
|
1276
1275
|
# Get wait_timeout (needed for Stop hook)
|
|
1277
1276
|
wait_timeout = get_config_value('wait_timeout', 1800)
|
|
1278
1277
|
|
|
1279
|
-
# Define all hooks
|
|
1278
|
+
# Define all hooks (PostToolUse removed - causes API 400 errors)
|
|
1280
1279
|
hook_configs = [
|
|
1281
1280
|
('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
|
|
1281
|
+
('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
|
|
1282
1282
|
('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
|
|
1283
|
-
('PostToolUse', '.*', f'{hook_cmd_base} post', None),
|
|
1283
|
+
# ('PostToolUse', '.*', f'{hook_cmd_base} post', None), # DISABLED
|
|
1284
1284
|
('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
|
|
1285
1285
|
('Notification', '', f'{hook_cmd_base} notify', None),
|
|
1286
1286
|
]
|
|
@@ -1324,9 +1324,9 @@ def verify_hooks_installed(settings_path):
|
|
|
1324
1324
|
if not settings:
|
|
1325
1325
|
return False
|
|
1326
1326
|
|
|
1327
|
-
# Check all hook types exist with HCOM commands
|
|
1327
|
+
# Check all hook types exist with HCOM commands (PostToolUse removed)
|
|
1328
1328
|
hooks = settings.get('hooks', {})
|
|
1329
|
-
for hook_type in ['SessionStart', 'PreToolUse', '
|
|
1329
|
+
for hook_type in ['SessionStart', 'PreToolUse', 'Stop', 'Notification']:
|
|
1330
1330
|
if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
|
|
1331
1331
|
for h in hooks.get(hook_type, [])):
|
|
1332
1332
|
return False
|
|
@@ -1362,7 +1362,7 @@ def is_process_alive(pid):
|
|
|
1362
1362
|
|
|
1363
1363
|
try:
|
|
1364
1364
|
pid = int(pid)
|
|
1365
|
-
except (TypeError, ValueError)
|
|
1365
|
+
except (TypeError, ValueError):
|
|
1366
1366
|
return False
|
|
1367
1367
|
|
|
1368
1368
|
if IS_WINDOWS:
|
|
@@ -1399,29 +1399,33 @@ def is_process_alive(pid):
|
|
|
1399
1399
|
|
|
1400
1400
|
kernel32.CloseHandle(handle)
|
|
1401
1401
|
return False # Couldn't get exit code
|
|
1402
|
-
except Exception
|
|
1402
|
+
except Exception:
|
|
1403
1403
|
return False
|
|
1404
1404
|
else:
|
|
1405
1405
|
# Unix: Use os.kill with signal 0
|
|
1406
1406
|
try:
|
|
1407
1407
|
os.kill(pid, 0)
|
|
1408
1408
|
return True
|
|
1409
|
-
except ProcessLookupError
|
|
1409
|
+
except ProcessLookupError:
|
|
1410
1410
|
return False
|
|
1411
|
-
except Exception
|
|
1411
|
+
except Exception:
|
|
1412
1412
|
return False
|
|
1413
1413
|
|
|
1414
|
-
|
|
1414
|
+
class LogParseResult(NamedTuple):
|
|
1415
|
+
"""Result from parsing log messages"""
|
|
1416
|
+
messages: list[dict[str, str]]
|
|
1417
|
+
end_position: int
|
|
1418
|
+
|
|
1419
|
+
def parse_log_messages(log_file: Path, start_pos: int = 0) -> LogParseResult:
|
|
1415
1420
|
"""Parse messages from log file
|
|
1416
1421
|
Args:
|
|
1417
1422
|
log_file: Path to log file
|
|
1418
1423
|
start_pos: Position to start reading from
|
|
1419
|
-
return_end_pos: If True, return tuple (messages, end_position)
|
|
1420
1424
|
Returns:
|
|
1421
|
-
|
|
1425
|
+
LogParseResult containing messages and end position
|
|
1422
1426
|
"""
|
|
1423
1427
|
if not log_file.exists():
|
|
1424
|
-
return ([], start_pos)
|
|
1428
|
+
return LogParseResult([], start_pos)
|
|
1425
1429
|
|
|
1426
1430
|
def read_messages(f):
|
|
1427
1431
|
f.seek(start_pos)
|
|
@@ -1429,7 +1433,7 @@ def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
|
|
|
1429
1433
|
end_pos = f.tell() # Capture actual end position
|
|
1430
1434
|
|
|
1431
1435
|
if not content.strip():
|
|
1432
|
-
return ([], end_pos)
|
|
1436
|
+
return LogParseResult([], end_pos)
|
|
1433
1437
|
|
|
1434
1438
|
messages = []
|
|
1435
1439
|
message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
|
|
@@ -1447,18 +1451,20 @@ def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
|
|
|
1447
1451
|
'message': message.replace('\\|', '|')
|
|
1448
1452
|
})
|
|
1449
1453
|
|
|
1450
|
-
return (messages, end_pos)
|
|
1454
|
+
return LogParseResult(messages, end_pos)
|
|
1451
1455
|
|
|
1452
|
-
|
|
1456
|
+
return read_file_with_retry(
|
|
1453
1457
|
log_file,
|
|
1454
1458
|
read_messages,
|
|
1455
|
-
default=([], start_pos)
|
|
1459
|
+
default=LogParseResult([], start_pos)
|
|
1456
1460
|
)
|
|
1457
1461
|
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
+
def get_unread_messages(instance_name: str, update_position: bool = False) -> list[dict[str, str]]:
|
|
1463
|
+
"""Get unread messages for instance with @-mention filtering
|
|
1464
|
+
Args:
|
|
1465
|
+
instance_name: Name of instance to get messages for
|
|
1466
|
+
update_position: If True, mark messages as read by updating position
|
|
1467
|
+
"""
|
|
1462
1468
|
log_file = hcom_path(LOG_FILE, ensure_parent=True)
|
|
1463
1469
|
|
|
1464
1470
|
if not log_file.exists():
|
|
@@ -1473,7 +1479,8 @@ def get_new_messages(instance_name):
|
|
|
1473
1479
|
last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
|
|
1474
1480
|
|
|
1475
1481
|
# Atomic read with position tracking
|
|
1476
|
-
|
|
1482
|
+
result = parse_log_messages(log_file, last_pos)
|
|
1483
|
+
all_messages, new_pos = result.messages, result.end_position
|
|
1477
1484
|
|
|
1478
1485
|
# Filter messages:
|
|
1479
1486
|
# 1. Exclude own messages
|
|
@@ -1485,12 +1492,13 @@ def get_new_messages(instance_name):
|
|
|
1485
1492
|
if should_deliver_message(msg, instance_name, all_instance_names):
|
|
1486
1493
|
messages.append(msg)
|
|
1487
1494
|
|
|
1488
|
-
#
|
|
1489
|
-
|
|
1495
|
+
# Only update position (ie mark as read) if explicitly requested (after successful delivery)
|
|
1496
|
+
if update_position:
|
|
1497
|
+
update_instance_position(instance_name, {'pos': new_pos})
|
|
1490
1498
|
|
|
1491
1499
|
return messages
|
|
1492
1500
|
|
|
1493
|
-
def format_age(seconds):
|
|
1501
|
+
def format_age(seconds: float) -> str:
|
|
1494
1502
|
"""Format time ago in human readable form"""
|
|
1495
1503
|
if seconds < 60:
|
|
1496
1504
|
return f"{int(seconds)}s"
|
|
@@ -1499,117 +1507,35 @@ def format_age(seconds):
|
|
|
1499
1507
|
else:
|
|
1500
1508
|
return f"{int(seconds/3600)}h"
|
|
1501
1509
|
|
|
1502
|
-
def
|
|
1503
|
-
"""
|
|
1504
|
-
|
|
1505
|
-
return "inactive", "", "", 0
|
|
1506
|
-
|
|
1507
|
-
def read_status(f):
|
|
1508
|
-
# Windows file buffering fix: read entire file to get current content
|
|
1509
|
-
if IS_WINDOWS:
|
|
1510
|
-
# Seek to beginning and read all content to bypass Windows file caching
|
|
1511
|
-
f.seek(0)
|
|
1512
|
-
all_content = f.read()
|
|
1513
|
-
all_lines = all_content.strip().split('\n')
|
|
1514
|
-
lines = all_lines[-5:] if len(all_lines) >= 5 else all_lines
|
|
1515
|
-
else:
|
|
1516
|
-
lines = f.readlines()[-5:]
|
|
1517
|
-
|
|
1518
|
-
for i, line in enumerate(reversed(lines)):
|
|
1519
|
-
try:
|
|
1520
|
-
entry = json.loads(line)
|
|
1521
|
-
timestamp = datetime.fromisoformat(entry['timestamp']).timestamp()
|
|
1522
|
-
age = int(time.time() - timestamp)
|
|
1523
|
-
entry_type = entry.get('type', '')
|
|
1524
|
-
|
|
1525
|
-
if entry['type'] == 'system':
|
|
1526
|
-
content = entry.get('content', '')
|
|
1527
|
-
if 'Running' in content:
|
|
1528
|
-
tool_name = content.split('Running ')[1].split('[')[0].strip()
|
|
1529
|
-
return "executing", f"({format_age(age)})", tool_name, timestamp
|
|
1530
|
-
|
|
1531
|
-
elif entry['type'] == 'assistant':
|
|
1532
|
-
content = entry.get('content', [])
|
|
1533
|
-
has_tool_use = any('tool_use' in str(item) for item in content)
|
|
1534
|
-
if has_tool_use:
|
|
1535
|
-
return "executing", f"({format_age(age)})", "tool", timestamp
|
|
1536
|
-
else:
|
|
1537
|
-
return "responding", f"({format_age(age)})", "", timestamp
|
|
1538
|
-
|
|
1539
|
-
elif entry['type'] == 'user':
|
|
1540
|
-
return "thinking", f"({format_age(age)})", "", timestamp
|
|
1541
|
-
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
1542
|
-
continue
|
|
1543
|
-
|
|
1544
|
-
return "inactive", "", "", 0
|
|
1545
|
-
|
|
1546
|
-
try:
|
|
1547
|
-
result = read_file_with_retry(
|
|
1548
|
-
transcript_path,
|
|
1549
|
-
read_status,
|
|
1550
|
-
default=("inactive", "", "", 0)
|
|
1551
|
-
)
|
|
1552
|
-
return result
|
|
1553
|
-
except Exception:
|
|
1554
|
-
return "inactive", "", "", 0
|
|
1555
|
-
|
|
1556
|
-
def get_instance_status(pos_data):
|
|
1557
|
-
"""Get current status of instance"""
|
|
1510
|
+
def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str]:
|
|
1511
|
+
"""Get current status of instance. Returns (status_type, age_string)."""
|
|
1512
|
+
# Returns: (display_category, formatted_age) - category for color, age for display
|
|
1558
1513
|
now = int(time.time())
|
|
1559
|
-
wait_timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
|
|
1560
|
-
|
|
1561
|
-
# Check if process is still alive. pid: null means killed
|
|
1562
|
-
# All real instances should have a PID (set by update_instance_with_pid)
|
|
1563
|
-
if 'pid' in pos_data:
|
|
1564
|
-
pid = pos_data['pid']
|
|
1565
|
-
if pid is None:
|
|
1566
|
-
# Explicitly null = was killed
|
|
1567
|
-
return "inactive", ""
|
|
1568
|
-
if not is_process_alive(pid):
|
|
1569
|
-
# On Windows, PID checks can fail during process transitions
|
|
1570
|
-
# Let timeout logic handle this using activity timestamps
|
|
1571
|
-
wait_timeout = 30 if IS_WINDOWS else wait_timeout # Shorter timeout when PID dead
|
|
1572
|
-
|
|
1573
|
-
last_permission = pos_data.get("last_permission_request", 0)
|
|
1574
|
-
last_stop = pos_data.get("last_stop", 0)
|
|
1575
|
-
last_tool = pos_data.get("last_tool", 0)
|
|
1576
|
-
|
|
1577
|
-
transcript_timestamp = 0
|
|
1578
|
-
transcript_status = "inactive"
|
|
1579
|
-
|
|
1580
|
-
transcript_path = pos_data.get("transcript_path", "")
|
|
1581
|
-
if transcript_path:
|
|
1582
|
-
status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
|
|
1583
|
-
transcript_status = status
|
|
1584
1514
|
|
|
1585
|
-
#
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
# Check timeout based on actual activity
|
|
1589
|
-
if last_activity > 0 and (now - last_activity) > wait_timeout:
|
|
1515
|
+
# Check if killed
|
|
1516
|
+
if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
|
|
1590
1517
|
return "inactive", ""
|
|
1591
1518
|
|
|
1592
|
-
#
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
(last_stop, "waiting"),
|
|
1596
|
-
(last_tool, "inactive"),
|
|
1597
|
-
(transcript_timestamp, transcript_status)
|
|
1598
|
-
]
|
|
1519
|
+
# Get last known status
|
|
1520
|
+
last_status = pos_data.get('last_status', '')
|
|
1521
|
+
last_status_time = pos_data.get('last_status_time', 0)
|
|
1599
1522
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
return "inactive", ""
|
|
1523
|
+
if not last_status or not last_status_time:
|
|
1524
|
+
return "unknown", ""
|
|
1603
1525
|
|
|
1604
|
-
|
|
1605
|
-
|
|
1526
|
+
# Get display category from STATUS_INFO
|
|
1527
|
+
display_status, _ = STATUS_INFO.get(last_status, ('unknown', ''))
|
|
1606
1528
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1529
|
+
# Check timeout
|
|
1530
|
+
age = now - last_status_time
|
|
1531
|
+
timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
|
|
1532
|
+
if age > timeout:
|
|
1533
|
+
return "inactive", ""
|
|
1609
1534
|
|
|
1610
|
-
|
|
1535
|
+
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1536
|
+
return display_status, f"({format_age(age)}){status_suffix}"
|
|
1611
1537
|
|
|
1612
|
-
def get_status_block(status_type):
|
|
1538
|
+
def get_status_block(status_type: str) -> str:
|
|
1613
1539
|
"""Get colored status block for a status type"""
|
|
1614
1540
|
color, symbol = STATUS_MAP.get(status_type, (BG_RED, "?"))
|
|
1615
1541
|
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
@@ -1660,7 +1586,7 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1660
1586
|
|
|
1661
1587
|
log_file = hcom_path(LOG_FILE)
|
|
1662
1588
|
if log_file.exists():
|
|
1663
|
-
messages = parse_log_messages(log_file)
|
|
1589
|
+
messages = parse_log_messages(log_file).messages
|
|
1664
1590
|
show_recent_messages(messages, limit, truncate=True)
|
|
1665
1591
|
|
|
1666
1592
|
def show_instances_by_directory():
|
|
@@ -1683,11 +1609,19 @@ def show_instances_by_directory():
|
|
|
1683
1609
|
for instance_name, pos_data in instances:
|
|
1684
1610
|
status_type, age = get_instance_status(pos_data)
|
|
1685
1611
|
status_block = get_status_block(status_type)
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1612
|
+
|
|
1613
|
+
# Format status description using STATUS_INFO and context
|
|
1614
|
+
last_status = pos_data.get('last_status', '')
|
|
1615
|
+
last_context = pos_data.get('last_status_context', '')
|
|
1616
|
+
_, desc_template = STATUS_INFO.get(last_status, ('unknown', ''))
|
|
1617
|
+
|
|
1618
|
+
# Format description with context if template has {}
|
|
1619
|
+
if '{}' in desc_template and last_context:
|
|
1620
|
+
status_desc = desc_template.format(last_context)
|
|
1621
|
+
else:
|
|
1622
|
+
status_desc = desc_template
|
|
1623
|
+
|
|
1624
|
+
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_desc} {age}{RESET}")
|
|
1691
1625
|
print()
|
|
1692
1626
|
else:
|
|
1693
1627
|
print(f" {DIM}Error reading instance data{RESET}")
|
|
@@ -1727,15 +1661,15 @@ def get_status_summary():
|
|
|
1727
1661
|
if not positions:
|
|
1728
1662
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1729
1663
|
|
|
1730
|
-
status_counts = {
|
|
1664
|
+
status_counts = {status: 0 for status in STATUS_MAP.keys()}
|
|
1731
1665
|
|
|
1732
|
-
for
|
|
1666
|
+
for _, pos_data in positions.items():
|
|
1733
1667
|
status_type, _ = get_instance_status(pos_data)
|
|
1734
1668
|
if status_type in status_counts:
|
|
1735
1669
|
status_counts[status_type] += 1
|
|
1736
1670
|
|
|
1737
1671
|
parts = []
|
|
1738
|
-
status_order = ["
|
|
1672
|
+
status_order = ["active", "delivered", "waiting", "blocked", "inactive", "unknown"]
|
|
1739
1673
|
|
|
1740
1674
|
for status_type in status_order:
|
|
1741
1675
|
count = status_counts[status_type]
|
|
@@ -1770,11 +1704,8 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1770
1704
|
defaults = {
|
|
1771
1705
|
"pos": 0,
|
|
1772
1706
|
"directory": str(Path.cwd()),
|
|
1773
|
-
"last_tool": 0,
|
|
1774
|
-
"last_tool_name": "unknown",
|
|
1775
1707
|
"last_stop": 0,
|
|
1776
|
-
"
|
|
1777
|
-
"session_ids": [session_id] if session_id else [],
|
|
1708
|
+
"session_id": session_id or "",
|
|
1778
1709
|
"transcript_path": "",
|
|
1779
1710
|
"notification_message": "",
|
|
1780
1711
|
"alias_announced": False
|
|
@@ -1807,12 +1738,19 @@ def update_instance_position(instance_name, update_fields):
|
|
|
1807
1738
|
else:
|
|
1808
1739
|
raise
|
|
1809
1740
|
|
|
1741
|
+
def set_status(instance_name: str, status: str, context: str = ''):
|
|
1742
|
+
"""Set instance status event with timestamp"""
|
|
1743
|
+
update_instance_position(instance_name, {
|
|
1744
|
+
'last_status': status,
|
|
1745
|
+
'last_status_time': int(time.time()),
|
|
1746
|
+
'last_status_context': context
|
|
1747
|
+
})
|
|
1748
|
+
log_hook_error(f"Set status for {instance_name} to {status} with context {context}") #TODO: change to 'log'?
|
|
1749
|
+
|
|
1810
1750
|
def merge_instance_data(to_data, from_data):
|
|
1811
1751
|
"""Merge instance data from from_data into to_data."""
|
|
1812
|
-
#
|
|
1813
|
-
|
|
1814
|
-
from_sessions = from_data.get('session_ids', [])
|
|
1815
|
-
to_data['session_ids'] = list(dict.fromkeys(to_sessions + from_sessions))
|
|
1752
|
+
# Use current session_id from source (overwrites previous)
|
|
1753
|
+
to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
|
|
1816
1754
|
|
|
1817
1755
|
# Update transient fields from source
|
|
1818
1756
|
to_data['pid'] = os.getppid() # Always use current PID
|
|
@@ -1824,14 +1762,16 @@ def merge_instance_data(to_data, from_data):
|
|
|
1824
1762
|
# Update directory to most recent
|
|
1825
1763
|
to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
|
|
1826
1764
|
|
|
1827
|
-
# Update
|
|
1828
|
-
to_data['last_tool'] = max(to_data.get('last_tool', 0), from_data.get('last_tool', 0))
|
|
1829
|
-
to_data['last_tool_name'] = from_data.get('last_tool_name', to_data.get('last_tool_name', 'unknown'))
|
|
1765
|
+
# Update heartbeat timestamp to most recent
|
|
1830
1766
|
to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
)
|
|
1767
|
+
|
|
1768
|
+
# Merge new status fields - take most recent status event
|
|
1769
|
+
from_time = from_data.get('last_status_time', 0)
|
|
1770
|
+
to_time = to_data.get('last_status_time', 0)
|
|
1771
|
+
if from_time > to_time:
|
|
1772
|
+
to_data['last_status'] = from_data.get('last_status', '')
|
|
1773
|
+
to_data['last_status_time'] = from_time
|
|
1774
|
+
to_data['last_status_context'] = from_data.get('last_status_context', '')
|
|
1835
1775
|
|
|
1836
1776
|
# Preserve background mode if set
|
|
1837
1777
|
to_data['background'] = to_data.get('background') or from_data.get('background')
|
|
@@ -1863,11 +1803,15 @@ def merge_instance_immediately(from_name, to_name):
|
|
|
1863
1803
|
from_data = load_instance_position(from_name)
|
|
1864
1804
|
to_data = load_instance_position(to_name)
|
|
1865
1805
|
|
|
1866
|
-
# Check if target
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1806
|
+
# Check if target has recent activity (time-based check instead of PID)
|
|
1807
|
+
now = time.time()
|
|
1808
|
+
last_activity = max(
|
|
1809
|
+
to_data.get('last_stop', 0),
|
|
1810
|
+
to_data.get('last_status_time', 0)
|
|
1811
|
+
)
|
|
1812
|
+
time_since_activity = now - last_activity
|
|
1813
|
+
if time_since_activity < MERGE_ACTIVITY_THRESHOLD:
|
|
1814
|
+
return f"Cannot recover {to_name}: instance is active (activity {int(time_since_activity)}s ago)"
|
|
1871
1815
|
|
|
1872
1816
|
# Merge data using helper
|
|
1873
1817
|
to_data = merge_instance_data(to_data, from_data)
|
|
@@ -1879,12 +1823,12 @@ def merge_instance_immediately(from_name, to_name):
|
|
|
1879
1823
|
# Cleanup source file only after successful save
|
|
1880
1824
|
try:
|
|
1881
1825
|
hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
|
|
1882
|
-
except:
|
|
1826
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
1883
1827
|
pass # Non-critical if cleanup fails
|
|
1884
1828
|
|
|
1885
|
-
return f"[SUCCESS] ✓ Recovered: {
|
|
1829
|
+
return f"[SUCCESS] ✓ Recovered alias: {to_name}"
|
|
1886
1830
|
except Exception:
|
|
1887
|
-
return f"Failed to
|
|
1831
|
+
return f"Failed to recover alias: {to_name}"
|
|
1888
1832
|
|
|
1889
1833
|
|
|
1890
1834
|
# ==================== Command Functions ====================
|
|
@@ -1896,9 +1840,8 @@ def show_main_screen_header():
|
|
|
1896
1840
|
log_file = hcom_path(LOG_FILE)
|
|
1897
1841
|
all_messages = []
|
|
1898
1842
|
if log_file.exists():
|
|
1899
|
-
all_messages = parse_log_messages(log_file)
|
|
1900
|
-
|
|
1901
|
-
|
|
1843
|
+
all_messages = parse_log_messages(log_file).messages
|
|
1844
|
+
|
|
1902
1845
|
print(f"{BOLD}HCOM{RESET} LOGS")
|
|
1903
1846
|
print(f"{DIM}{'─'*40}{RESET}\n")
|
|
1904
1847
|
|
|
@@ -1947,13 +1890,15 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
|
|
|
1947
1890
|
|
|
1948
1891
|
=== ADDITIONAL INFO ===
|
|
1949
1892
|
|
|
1950
|
-
CONCEPT: HCOM
|
|
1951
|
-
|
|
1893
|
+
CONCEPT: HCOM launches Claude Code instances in new terminal windows.
|
|
1894
|
+
They communicate with each other via a shared conversation.
|
|
1895
|
+
You communicate with them via hcom automation commands.
|
|
1952
1896
|
|
|
1953
1897
|
KEY UNDERSTANDING:
|
|
1954
1898
|
• Single conversation - All instances share ~/.hcom/hcom.log
|
|
1955
|
-
•
|
|
1956
|
-
•
|
|
1899
|
+
• Messaging - Use 'hcom send "message"' from CLI to send messages to instances
|
|
1900
|
+
• Instances receive messages via hooks automatically and send with 'eval $HCOM send "message"'
|
|
1901
|
+
• hcom open is directory-specific - always cd to project directory first
|
|
1957
1902
|
• hcom watch --wait outputs existing logs, then waits for the next message, prints it, and exits.
|
|
1958
1903
|
Times out after [seconds]
|
|
1959
1904
|
• Named agents are custom system prompts created by users/claude code.
|
|
@@ -1961,7 +1906,7 @@ Times out after [seconds]
|
|
|
1961
1906
|
|
|
1962
1907
|
LAUNCH PATTERNS:
|
|
1963
1908
|
hcom open 2 reviewer # 2 generic + 1 reviewer agent
|
|
1964
|
-
hcom open reviewer reviewer # 2 separate reviewer instances
|
|
1909
|
+
hcom open reviewer reviewer # 2 separate reviewer instances
|
|
1965
1910
|
hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
|
|
1966
1911
|
hcom open --claude-args "--model sonnet" # Pass 'claude' CLI flags
|
|
1967
1912
|
hcom open --background (or -p) then hcom kill # Detached background process
|
|
@@ -1975,10 +1920,12 @@ LAUNCH PATTERNS:
|
|
|
1975
1920
|
(Unmatched @mentions broadcast to everyone)
|
|
1976
1921
|
|
|
1977
1922
|
STATUS INDICATORS:
|
|
1978
|
-
•
|
|
1923
|
+
• ▶ active - instance is working (processing/executing)
|
|
1924
|
+
• ▷ delivered - instance just received a message
|
|
1979
1925
|
• ◉ waiting - instance is waiting for new messages
|
|
1980
1926
|
• ■ blocked - instance is blocked by permission request (needs user approval)
|
|
1981
1927
|
• ○ inactive - instance is timed out, disconnected, etc
|
|
1928
|
+
• ○ unknown - no status information available
|
|
1982
1929
|
|
|
1983
1930
|
CONFIG:
|
|
1984
1931
|
Config file (persistent): ~/.hcom/config.json
|
|
@@ -2011,14 +1958,6 @@ def cmd_open(*args):
|
|
|
2011
1958
|
# Parse arguments
|
|
2012
1959
|
instances, prefix, claude_args, background = parse_open_args(list(args))
|
|
2013
1960
|
|
|
2014
|
-
# Extract resume sessionId if present
|
|
2015
|
-
resume_session_id = None
|
|
2016
|
-
if claude_args:
|
|
2017
|
-
for i, arg in enumerate(claude_args):
|
|
2018
|
-
if arg in ['--resume', '-r'] and i + 1 < len(claude_args):
|
|
2019
|
-
resume_session_id = claude_args[i + 1]
|
|
2020
|
-
break
|
|
2021
|
-
|
|
2022
1961
|
# Add -p flag and stream-json output for background mode if not already present
|
|
2023
1962
|
if background and '-p' not in claude_args and '--print' not in claude_args:
|
|
2024
1963
|
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
@@ -2049,32 +1988,25 @@ def cmd_open(*args):
|
|
|
2049
1988
|
# Build environment variables for Claude instances
|
|
2050
1989
|
base_env = build_claude_env()
|
|
2051
1990
|
|
|
2052
|
-
# Pass resume sessionId to hooks (only for first instance if multiple)
|
|
2053
|
-
# This avoids conflicts when resuming with -n > 1
|
|
2054
|
-
if resume_session_id:
|
|
2055
|
-
if len(instances) > 1:
|
|
2056
|
-
print(f"Warning: --resume with {len(instances)} instances will only resume the first instance", file=sys.stderr)
|
|
2057
|
-
# Will be added to first instance env only
|
|
2058
|
-
|
|
2059
1991
|
# Add prefix-specific hints if provided
|
|
2060
1992
|
if prefix:
|
|
2061
1993
|
base_env['HCOM_PREFIX'] = prefix
|
|
2062
|
-
|
|
1994
|
+
send_cmd = build_send_command()
|
|
1995
|
+
hint = f"To respond to {prefix} group: {send_cmd} '@{prefix} message'"
|
|
2063
1996
|
base_env['HCOM_INSTANCE_HINTS'] = hint
|
|
2064
|
-
|
|
2065
|
-
first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
|
|
1997
|
+
first_use = f"You're in the {prefix} group. Use {prefix} to message: {send_cmd} '@{prefix} message'"
|
|
2066
1998
|
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
2067
1999
|
|
|
2068
2000
|
launched = 0
|
|
2069
2001
|
initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
|
|
2070
2002
|
|
|
2071
|
-
for
|
|
2003
|
+
for _, instance_type in enumerate(instances):
|
|
2072
2004
|
instance_env = base_env.copy()
|
|
2073
2005
|
|
|
2074
|
-
#
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2006
|
+
# Set unique launch ID for sender detection in cmd_send()
|
|
2007
|
+
launch_id = f"{int(time.time())}_{random.randint(10000, 99999)}"
|
|
2008
|
+
instance_env['HCOM_LAUNCH_ID'] = launch_id
|
|
2009
|
+
|
|
2078
2010
|
# Mark background instances via environment with log filename
|
|
2079
2011
|
if background:
|
|
2080
2012
|
# Generate unique log filename
|
|
@@ -2084,7 +2016,7 @@ def cmd_open(*args):
|
|
|
2084
2016
|
# Build claude command
|
|
2085
2017
|
if instance_type == 'generic':
|
|
2086
2018
|
# Generic instance - no agent content
|
|
2087
|
-
claude_cmd,
|
|
2019
|
+
claude_cmd, _ = build_claude_command(
|
|
2088
2020
|
agent_content=None,
|
|
2089
2021
|
claude_args=claude_args,
|
|
2090
2022
|
initial_prompt=initial_prompt
|
|
@@ -2101,7 +2033,7 @@ def cmd_open(*args):
|
|
|
2101
2033
|
# Use agent's model and tools if specified and not overridden in claude_args
|
|
2102
2034
|
agent_model = agent_config.get('model')
|
|
2103
2035
|
agent_tools = agent_config.get('tools')
|
|
2104
|
-
claude_cmd,
|
|
2036
|
+
claude_cmd, _ = build_claude_command(
|
|
2105
2037
|
agent_content=agent_content,
|
|
2106
2038
|
claude_args=claude_args,
|
|
2107
2039
|
initial_prompt=initial_prompt,
|
|
@@ -2217,7 +2149,7 @@ def cmd_watch(*args):
|
|
|
2217
2149
|
# Atomic position capture BEFORE parsing (prevents race condition)
|
|
2218
2150
|
if log_file.exists():
|
|
2219
2151
|
last_pos = log_file.stat().st_size # Capture position first
|
|
2220
|
-
messages = parse_log_messages(log_file)
|
|
2152
|
+
messages = parse_log_messages(log_file).messages
|
|
2221
2153
|
else:
|
|
2222
2154
|
last_pos = 0
|
|
2223
2155
|
messages = []
|
|
@@ -2229,7 +2161,7 @@ def cmd_watch(*args):
|
|
|
2229
2161
|
|
|
2230
2162
|
# Status to stderr, data to stdout
|
|
2231
2163
|
if recent_messages:
|
|
2232
|
-
print(f'---Showing last 5 seconds of messages---', file=sys.stderr)
|
|
2164
|
+
print(f'---Showing last 5 seconds of messages---', file=sys.stderr) #TODO: change this to recent messages and have logic like last 3 messages + all messages in last 5 seconds.
|
|
2233
2165
|
for msg in recent_messages:
|
|
2234
2166
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2235
2167
|
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
@@ -2245,7 +2177,7 @@ def cmd_watch(*args):
|
|
|
2245
2177
|
new_messages = []
|
|
2246
2178
|
if current_size > last_pos:
|
|
2247
2179
|
# Capture new position BEFORE parsing (atomic)
|
|
2248
|
-
new_messages = parse_log_messages(log_file, last_pos)
|
|
2180
|
+
new_messages = parse_log_messages(log_file, last_pos).messages
|
|
2249
2181
|
if new_messages:
|
|
2250
2182
|
for msg in new_messages:
|
|
2251
2183
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
@@ -2282,9 +2214,10 @@ def cmd_watch(*args):
|
|
|
2282
2214
|
"status": status,
|
|
2283
2215
|
"age": age.strip() if age else "",
|
|
2284
2216
|
"directory": data.get("directory", "unknown"),
|
|
2285
|
-
"
|
|
2286
|
-
"
|
|
2287
|
-
"
|
|
2217
|
+
"session_id": data.get("session_id", ""),
|
|
2218
|
+
"last_status": data.get("last_status", ""),
|
|
2219
|
+
"last_status_time": data.get("last_status_time", 0),
|
|
2220
|
+
"last_status_context": data.get("last_status_context", ""),
|
|
2288
2221
|
"pid": data.get("pid"),
|
|
2289
2222
|
"background": bool(data.get("background"))
|
|
2290
2223
|
}
|
|
@@ -2293,7 +2226,7 @@ def cmd_watch(*args):
|
|
|
2293
2226
|
# Get recent messages
|
|
2294
2227
|
messages = []
|
|
2295
2228
|
if log_file.exists():
|
|
2296
|
-
all_messages = parse_log_messages(log_file)
|
|
2229
|
+
all_messages = parse_log_messages(log_file).messages
|
|
2297
2230
|
messages = all_messages[-5:] if all_messages else []
|
|
2298
2231
|
|
|
2299
2232
|
# Output JSON
|
|
@@ -2313,6 +2246,7 @@ def cmd_watch(*args):
|
|
|
2313
2246
|
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
2314
2247
|
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
2315
2248
|
print(" hcom watch --wait Wait for new messages", file=sys.stderr)
|
|
2249
|
+
print(" Full information: hcom --help")
|
|
2316
2250
|
|
|
2317
2251
|
show_cli_hints()
|
|
2318
2252
|
|
|
@@ -2357,7 +2291,7 @@ def cmd_watch(*args):
|
|
|
2357
2291
|
if log_file.exists():
|
|
2358
2292
|
current_size = log_file.stat().st_size
|
|
2359
2293
|
if current_size > last_pos:
|
|
2360
|
-
new_messages = parse_log_messages(log_file, last_pos)
|
|
2294
|
+
new_messages = parse_log_messages(log_file, last_pos).messages
|
|
2361
2295
|
# Use the last known status for consistency
|
|
2362
2296
|
status_line_text = f"{last_status}{status_suffix}"
|
|
2363
2297
|
for msg in new_messages:
|
|
@@ -2367,9 +2301,9 @@ def cmd_watch(*args):
|
|
|
2367
2301
|
# Check for keyboard input
|
|
2368
2302
|
ready_for_input = False
|
|
2369
2303
|
if IS_WINDOWS:
|
|
2370
|
-
import msvcrt
|
|
2371
|
-
if msvcrt.kbhit():
|
|
2372
|
-
msvcrt.getch()
|
|
2304
|
+
import msvcrt # type: ignore[import]
|
|
2305
|
+
if msvcrt.kbhit(): # type: ignore[attr-defined]
|
|
2306
|
+
msvcrt.getch() # type: ignore[attr-defined]
|
|
2373
2307
|
ready_for_input = True
|
|
2374
2308
|
else:
|
|
2375
2309
|
if select.select([sys.stdin], [], [], 0.1)[0]:
|
|
@@ -2428,6 +2362,13 @@ def cmd_clear():
|
|
|
2428
2362
|
if script_count > 0:
|
|
2429
2363
|
print(f"Cleaned up {script_count} old script files")
|
|
2430
2364
|
|
|
2365
|
+
# Clean up old launch mapping files (older than 24 hours)
|
|
2366
|
+
if instances_dir.exists():
|
|
2367
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2368
|
+
mapping_count = sum(1 for f in instances_dir.glob('.launch_map_*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
|
|
2369
|
+
if mapping_count > 0:
|
|
2370
|
+
print(f"Cleaned up {mapping_count} old launch mapping files")
|
|
2371
|
+
|
|
2431
2372
|
# Check if hcom files exist
|
|
2432
2373
|
if not log_file.exists() and not instances_dir.exists():
|
|
2433
2374
|
print("No hcom conversation to clear")
|
|
@@ -2458,11 +2399,15 @@ def cmd_clear():
|
|
|
2458
2399
|
if has_instances:
|
|
2459
2400
|
archive_instances = session_archive / INSTANCES_DIR
|
|
2460
2401
|
archive_instances.mkdir(exist_ok=True)
|
|
2461
|
-
|
|
2402
|
+
|
|
2462
2403
|
# Move json files only
|
|
2463
2404
|
for f in instances_dir.glob('*.json'):
|
|
2464
2405
|
f.rename(archive_instances / f.name)
|
|
2465
|
-
|
|
2406
|
+
|
|
2407
|
+
# Clean up orphaned mapping files (position files are archived)
|
|
2408
|
+
for f in instances_dir.glob('.launch_map_*'):
|
|
2409
|
+
f.unlink(missing_ok=True)
|
|
2410
|
+
|
|
2466
2411
|
archived = True
|
|
2467
2412
|
else:
|
|
2468
2413
|
# Clean up empty files/dirs
|
|
@@ -2506,14 +2451,15 @@ def cleanup_directory_hooks(directory):
|
|
|
2506
2451
|
|
|
2507
2452
|
hooks_found = False
|
|
2508
2453
|
|
|
2454
|
+
# Include PostToolUse for backward compatibility cleanup
|
|
2509
2455
|
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2510
|
-
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2511
|
-
|
|
2456
|
+
for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2457
|
+
|
|
2512
2458
|
_remove_hcom_hooks_from_settings(settings)
|
|
2513
|
-
|
|
2459
|
+
|
|
2514
2460
|
# Check if any were removed
|
|
2515
2461
|
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2516
|
-
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2462
|
+
for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2517
2463
|
if new_hook_count < original_hook_count:
|
|
2518
2464
|
hooks_found = True
|
|
2519
2465
|
|
|
@@ -2577,6 +2523,7 @@ def cmd_kill(*args):
|
|
|
2577
2523
|
|
|
2578
2524
|
# Mark instance as killed
|
|
2579
2525
|
update_instance_position(target_name, {'pid': None})
|
|
2526
|
+
set_status(target_name, 'killed')
|
|
2580
2527
|
|
|
2581
2528
|
if not instance_name:
|
|
2582
2529
|
print(f"Killed {killed_count} instance(s)")
|
|
@@ -2645,35 +2592,58 @@ def cmd_send(message):
|
|
|
2645
2592
|
# Check if hcom files exist
|
|
2646
2593
|
log_file = hcom_path(LOG_FILE)
|
|
2647
2594
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2648
|
-
|
|
2595
|
+
|
|
2649
2596
|
if not log_file.exists() and not instances_dir.exists():
|
|
2650
2597
|
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2651
2598
|
return 1
|
|
2652
|
-
|
|
2599
|
+
|
|
2653
2600
|
# Validate message
|
|
2654
2601
|
error = validate_message(message)
|
|
2655
2602
|
if error:
|
|
2656
2603
|
print(error, file=sys.stderr)
|
|
2657
2604
|
return 1
|
|
2658
|
-
|
|
2605
|
+
|
|
2659
2606
|
# Check for unmatched mentions (minimal warning)
|
|
2660
2607
|
mentions = MENTION_PATTERN.findall(message)
|
|
2661
2608
|
if mentions:
|
|
2662
2609
|
try:
|
|
2663
2610
|
positions = load_all_positions()
|
|
2664
2611
|
all_instances = list(positions.keys())
|
|
2665
|
-
unmatched = [m for m in mentions
|
|
2612
|
+
unmatched = [m for m in mentions
|
|
2666
2613
|
if not any(name.lower().startswith(m.lower()) for name in all_instances)]
|
|
2667
2614
|
if unmatched:
|
|
2668
2615
|
print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
|
|
2669
2616
|
except Exception:
|
|
2670
2617
|
pass # Don't fail on warning
|
|
2671
|
-
|
|
2672
|
-
#
|
|
2673
|
-
sender_name =
|
|
2674
|
-
|
|
2618
|
+
|
|
2619
|
+
# Determine sender: lookup by launch_id, fallback to config
|
|
2620
|
+
sender_name = None
|
|
2621
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2622
|
+
if launch_id:
|
|
2623
|
+
try:
|
|
2624
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
|
|
2625
|
+
if mapping_file.exists():
|
|
2626
|
+
sender_name = mapping_file.read_text(encoding='utf-8').strip()
|
|
2627
|
+
except Exception:
|
|
2628
|
+
pass
|
|
2629
|
+
|
|
2630
|
+
if not sender_name:
|
|
2631
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
2632
|
+
|
|
2675
2633
|
if send_message(sender_name, message):
|
|
2676
|
-
|
|
2634
|
+
# For instances: check for new messages and display immediately
|
|
2635
|
+
if launch_id: # Only for instances with HCOM_LAUNCH_ID
|
|
2636
|
+
messages = get_unread_messages(sender_name, update_position=True)
|
|
2637
|
+
if messages:
|
|
2638
|
+
max_msgs = get_config_value('max_messages_per_delivery', 50)
|
|
2639
|
+
messages_to_show = messages[:max_msgs]
|
|
2640
|
+
formatted = format_hook_messages(messages_to_show, sender_name)
|
|
2641
|
+
print(f"Message sent\n\n{formatted}", file=sys.stderr)
|
|
2642
|
+
else:
|
|
2643
|
+
print("Message sent", file=sys.stderr)
|
|
2644
|
+
else:
|
|
2645
|
+
# Bigboss: just confirm send
|
|
2646
|
+
print("Message sent", file=sys.stderr)
|
|
2677
2647
|
|
|
2678
2648
|
# Show cli_hints if configured (non-interactive mode)
|
|
2679
2649
|
if not is_interactive():
|
|
@@ -2684,6 +2654,49 @@ def cmd_send(message):
|
|
|
2684
2654
|
print(format_error("Failed to send message"), file=sys.stderr)
|
|
2685
2655
|
return 1
|
|
2686
2656
|
|
|
2657
|
+
def cmd_resume_merge(alias: str) -> int:
|
|
2658
|
+
"""Resume/merge current instance into an existing instance by alias.
|
|
2659
|
+
|
|
2660
|
+
INTERNAL COMMAND: Only called via '$HCOM send --resume alias' during implicit resume workflow.
|
|
2661
|
+
Not meant for direct CLI usage.
|
|
2662
|
+
"""
|
|
2663
|
+
# Get current instance name via launch_id mapping (same mechanism as cmd_send)
|
|
2664
|
+
# The mapping is created by init_hook_context() when hooks run
|
|
2665
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2666
|
+
if not launch_id:
|
|
2667
|
+
print(format_error("Not in HCOM instance context - no launch ID"), file=sys.stderr)
|
|
2668
|
+
return 1
|
|
2669
|
+
|
|
2670
|
+
instance_name = None
|
|
2671
|
+
try:
|
|
2672
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
|
|
2673
|
+
if mapping_file.exists():
|
|
2674
|
+
instance_name = mapping_file.read_text(encoding='utf-8').strip()
|
|
2675
|
+
except Exception:
|
|
2676
|
+
pass
|
|
2677
|
+
|
|
2678
|
+
if not instance_name:
|
|
2679
|
+
print(format_error("Could not determine instance name"), file=sys.stderr)
|
|
2680
|
+
return 1
|
|
2681
|
+
|
|
2682
|
+
# Sanitize alias: only allow alphanumeric, dash, underscore
|
|
2683
|
+
# This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
|
|
2684
|
+
if not re.match(r'^[A-Za-z0-9\-_]+$', alias):
|
|
2685
|
+
print(format_error("Invalid alias format. Use alphanumeric, dash, or underscore only"), file=sys.stderr)
|
|
2686
|
+
return 1
|
|
2687
|
+
|
|
2688
|
+
# Attempt to merge current instance into target alias
|
|
2689
|
+
status = merge_instance_immediately(instance_name, alias)
|
|
2690
|
+
|
|
2691
|
+
# Handle results
|
|
2692
|
+
if not status:
|
|
2693
|
+
# Empty status means names matched (from_name == to_name)
|
|
2694
|
+
status = f"[SUCCESS] ✓ Already using alias {alias}"
|
|
2695
|
+
|
|
2696
|
+
# Print status and return
|
|
2697
|
+
print(status, file=sys.stderr)
|
|
2698
|
+
return 0 if status.startswith('[SUCCESS]') else 1
|
|
2699
|
+
|
|
2687
2700
|
# ==================== Hook Helpers ====================
|
|
2688
2701
|
|
|
2689
2702
|
def format_hook_messages(messages, instance_name):
|
|
@@ -2693,13 +2706,7 @@ def format_hook_messages(messages, instance_name):
|
|
|
2693
2706
|
reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
|
|
2694
2707
|
else:
|
|
2695
2708
|
parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
|
|
2696
|
-
reason = f"[{len(messages)} new messages] |
|
|
2697
|
-
|
|
2698
|
-
# Check alias announcement
|
|
2699
|
-
instance_data = load_instance_position(instance_name)
|
|
2700
|
-
if not instance_data.get('alias_announced', False) and not instance_name.endswith('claude'):
|
|
2701
|
-
reason = f"{reason} | [Alias assigned: {instance_name}] <Your hcom chat alias is {instance_name}. You can at-mention others in hcom chat by their alias to DM them. (alias1 → alias2 means alias1 sent the message to the entire group, if there is an at symbol in the message then it is targeted)>"
|
|
2702
|
-
update_instance_position(instance_name, {'alias_announced': True})
|
|
2709
|
+
reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
|
|
2703
2710
|
|
|
2704
2711
|
# Only append instance_hints to messages (first_use_text is handled separately)
|
|
2705
2712
|
instance_hints = get_config_value('instance_hints', '')
|
|
@@ -2708,149 +2715,76 @@ def format_hook_messages(messages, instance_name):
|
|
|
2708
2715
|
|
|
2709
2716
|
return reason
|
|
2710
2717
|
|
|
2711
|
-
def get_pending_tools(transcript_path, max_lines=100):
|
|
2712
|
-
"""Parse transcript to find tool_use IDs without matching tool_results.
|
|
2713
|
-
Returns count of pending tools."""
|
|
2714
|
-
if not transcript_path or not os.path.exists(transcript_path):
|
|
2715
|
-
return 0
|
|
2716
|
-
|
|
2717
|
-
tool_uses = set()
|
|
2718
|
-
tool_results = set()
|
|
2719
|
-
|
|
2720
|
-
try:
|
|
2721
|
-
# Read last N lines efficiently
|
|
2722
|
-
with open(transcript_path, 'rb') as f:
|
|
2723
|
-
# Seek to end and read backwards
|
|
2724
|
-
f.seek(0, 2) # Go to end
|
|
2725
|
-
file_size = f.tell()
|
|
2726
|
-
read_size = min(file_size, max_lines * 500) # Assume ~500 bytes per line
|
|
2727
|
-
f.seek(max(0, file_size - read_size))
|
|
2728
|
-
recent_content = f.read().decode('utf-8', errors='ignore')
|
|
2729
|
-
|
|
2730
|
-
# Parse line by line (handle both Unix \n and Windows \r\n)
|
|
2731
|
-
for line in recent_content.splitlines():
|
|
2732
|
-
if not line.strip():
|
|
2733
|
-
continue
|
|
2734
|
-
try:
|
|
2735
|
-
data = json.loads(line)
|
|
2736
|
-
|
|
2737
|
-
# Check for tool_use blocks in assistant messages
|
|
2738
|
-
if data.get('type') == 'assistant':
|
|
2739
|
-
content = data.get('message', {}).get('content', [])
|
|
2740
|
-
if isinstance(content, list):
|
|
2741
|
-
for item in content:
|
|
2742
|
-
if isinstance(item, dict) and item.get('type') == 'tool_use':
|
|
2743
|
-
tool_id = item.get('id')
|
|
2744
|
-
if tool_id:
|
|
2745
|
-
tool_uses.add(tool_id)
|
|
2746
|
-
|
|
2747
|
-
# Check for tool_results in user messages
|
|
2748
|
-
elif data.get('type') == 'user':
|
|
2749
|
-
content = data.get('message', {}).get('content', [])
|
|
2750
|
-
if isinstance(content, list):
|
|
2751
|
-
for item in content:
|
|
2752
|
-
if isinstance(item, dict) and item.get('type') == 'tool_result':
|
|
2753
|
-
tool_id = item.get('tool_use_id')
|
|
2754
|
-
if tool_id:
|
|
2755
|
-
tool_results.add(tool_id)
|
|
2756
|
-
except Exception as e:
|
|
2757
|
-
continue
|
|
2758
|
-
|
|
2759
|
-
# Return count of pending tools
|
|
2760
|
-
pending = tool_uses - tool_results
|
|
2761
|
-
return len(pending)
|
|
2762
|
-
except Exception as e:
|
|
2763
|
-
return 0 # On any error, assume no pending tools
|
|
2764
|
-
|
|
2765
2718
|
# ==================== Hook Handlers ====================
|
|
2766
2719
|
|
|
2767
|
-
def init_hook_context(hook_data):
|
|
2720
|
+
def init_hook_context(hook_data, hook_type=None):
|
|
2768
2721
|
"""Initialize instance context - shared by post/stop/notify hooks"""
|
|
2722
|
+
import time
|
|
2723
|
+
|
|
2769
2724
|
session_id = hook_data.get('session_id', '')
|
|
2770
2725
|
transcript_path = hook_data.get('transcript_path', '')
|
|
2771
2726
|
prefix = os.environ.get('HCOM_PREFIX')
|
|
2772
2727
|
|
|
2773
|
-
# Check if this is a resume operation
|
|
2774
|
-
resume_session_id = os.environ.get('HCOM_RESUME_SESSION_ID')
|
|
2775
2728
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2776
2729
|
instance_name = None
|
|
2777
2730
|
merged_state = None
|
|
2778
2731
|
|
|
2779
|
-
#
|
|
2780
|
-
|
|
2781
|
-
for instance_file in instances_dir.glob("*.json"):
|
|
2782
|
-
try:
|
|
2783
|
-
data = load_instance_position(instance_file.stem)
|
|
2784
|
-
# Check if resume_session_id matches any in the session_ids array
|
|
2785
|
-
old_session_ids = data.get('session_ids', [])
|
|
2786
|
-
if resume_session_id in old_session_ids:
|
|
2787
|
-
# Found the instance! Keep the same name
|
|
2788
|
-
instance_name = instance_file.stem
|
|
2789
|
-
merged_state = data
|
|
2790
|
-
# Append new session_id to array, update transcript_path to current
|
|
2791
|
-
if session_id and session_id not in old_session_ids:
|
|
2792
|
-
merged_state.setdefault('session_ids', old_session_ids).append(session_id)
|
|
2793
|
-
if transcript_path:
|
|
2794
|
-
merged_state['transcript_path'] = transcript_path
|
|
2795
|
-
break
|
|
2796
|
-
except:
|
|
2797
|
-
continue
|
|
2798
|
-
|
|
2799
|
-
# Check if current session exists in any instance's session_ids array
|
|
2800
|
-
# This maintains identity after implicit HCOM_RESUME
|
|
2732
|
+
# Check if current session_id matches any existing instance
|
|
2733
|
+
# This maintains identity after resume/merge operations
|
|
2801
2734
|
if not instance_name and session_id and instances_dir.exists():
|
|
2802
2735
|
for instance_file in instances_dir.glob("*.json"):
|
|
2803
2736
|
try:
|
|
2804
2737
|
data = load_instance_position(instance_file.stem)
|
|
2805
|
-
if session_id
|
|
2738
|
+
if session_id == data.get('session_id'):
|
|
2806
2739
|
instance_name = instance_file.stem
|
|
2807
2740
|
merged_state = data
|
|
2741
|
+
log_hook_error(f'DEBUG: Session_id {session_id[:8]} matched {instance_file.stem}, reusing that name')
|
|
2808
2742
|
break
|
|
2809
|
-
except:
|
|
2743
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
2810
2744
|
continue
|
|
2811
2745
|
|
|
2812
2746
|
# If not found or not resuming, generate new name from session_id
|
|
2813
2747
|
if not instance_name:
|
|
2814
2748
|
instance_name = get_display_name(session_id, prefix)
|
|
2749
|
+
# DEBUG: Log name generation
|
|
2750
|
+
log_hook_error(f'DEBUG: Generated instance_name={instance_name} from session_id={session_id[:8] if session_id else "None"}')
|
|
2815
2751
|
|
|
2816
|
-
#
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
# Found duplicate with same PID - merge and delete
|
|
2826
|
-
if not merged_state:
|
|
2827
|
-
merged_state = data
|
|
2828
|
-
else:
|
|
2829
|
-
# Merge useful fields from duplicate
|
|
2830
|
-
merged_state = merge_instance_data(merged_state, data)
|
|
2831
|
-
instance_file.unlink() # Delete the duplicate file
|
|
2832
|
-
# Don't break - could have multiple duplicates with same PID
|
|
2833
|
-
except:
|
|
2834
|
-
continue
|
|
2752
|
+
# Save launch_id → instance_name mapping for cmd_send()
|
|
2753
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2754
|
+
if launch_id:
|
|
2755
|
+
try:
|
|
2756
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}', ensure_parent=True)
|
|
2757
|
+
mapping_file.write_text(instance_name, encoding='utf-8')
|
|
2758
|
+
log_hook_error(f'DEBUG: FINAL - Wrote launch_map_{launch_id} → {instance_name} (session_id={session_id[:8] if session_id else "None"})')
|
|
2759
|
+
except Exception:
|
|
2760
|
+
pass # Non-critical
|
|
2835
2761
|
|
|
2836
2762
|
# Save migrated data if we have it
|
|
2837
2763
|
if merged_state:
|
|
2838
2764
|
save_instance_position(instance_name, merged_state)
|
|
2839
2765
|
|
|
2840
|
-
|
|
2841
|
-
|
|
2766
|
+
# Check if instance is brand new or pre-existing (before creation (WWJD))
|
|
2767
|
+
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
2768
|
+
is_new_instance = not instance_file.exists()
|
|
2769
|
+
|
|
2770
|
+
# Skip instance creation for unmatched SessionStart resumes (prevents orphans)
|
|
2771
|
+
# Instance will be created in UserPromptSubmit with correct session_id
|
|
2772
|
+
should_create_instance = not (
|
|
2773
|
+
hook_type == 'sessionstart' and
|
|
2774
|
+
hook_data.get('source', 'startup') == 'resume' and not merged_state
|
|
2775
|
+
)
|
|
2776
|
+
if should_create_instance:
|
|
2777
|
+
initialize_instance_in_position_file(instance_name, session_id)
|
|
2778
|
+
existing_data = load_instance_position(instance_name) if should_create_instance else {}
|
|
2842
2779
|
|
|
2843
|
-
# Prepare updates
|
|
2844
|
-
updates = {
|
|
2780
|
+
# Prepare updates
|
|
2781
|
+
updates: dict[str, Any] = {
|
|
2845
2782
|
'directory': str(Path.cwd()),
|
|
2846
2783
|
}
|
|
2847
2784
|
|
|
2848
|
-
# Update
|
|
2785
|
+
# Update session_id (overwrites previous)
|
|
2849
2786
|
if session_id:
|
|
2850
|
-
|
|
2851
|
-
if session_id not in current_session_ids:
|
|
2852
|
-
current_session_ids.append(session_id)
|
|
2853
|
-
updates['session_ids'] = current_session_ids
|
|
2787
|
+
updates['session_id'] = session_id
|
|
2854
2788
|
|
|
2855
2789
|
# Update transcript_path to current
|
|
2856
2790
|
if transcript_path:
|
|
@@ -2865,276 +2799,205 @@ def init_hook_context(hook_data):
|
|
|
2865
2799
|
updates['background'] = True
|
|
2866
2800
|
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
2867
2801
|
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
"""Extract command payload with quote stripping"""
|
|
2872
|
-
marker = f'{prefix}:'
|
|
2873
|
-
if marker not in command:
|
|
2874
|
-
return None
|
|
2875
|
-
|
|
2876
|
-
parts = command.split(marker, 1)
|
|
2877
|
-
if len(parts) <= 1:
|
|
2878
|
-
return None
|
|
2879
|
-
|
|
2880
|
-
payload = parts[1].strip()
|
|
2881
|
-
|
|
2882
|
-
# Complex quote stripping logic (preserves exact behavior)
|
|
2883
|
-
if len(payload) >= 2 and \
|
|
2884
|
-
((payload[0] == '"' and payload[-1] == '"') or \
|
|
2885
|
-
(payload[0] == "'" and payload[-1] == "'")):
|
|
2886
|
-
payload = payload[1:-1]
|
|
2887
|
-
elif payload and payload[-1] in '"\'':
|
|
2888
|
-
payload = payload[:-1]
|
|
2889
|
-
|
|
2890
|
-
return payload if payload else None
|
|
2891
|
-
|
|
2892
|
-
def _sanitize_alias(alias):
|
|
2893
|
-
"""Sanitize extracted alias: strip quotes/backticks, stop at first invalid char/whitespace."""
|
|
2894
|
-
alias = alias.strip()
|
|
2895
|
-
# Strip wrapping quotes/backticks iteratively
|
|
2896
|
-
for _ in range(3):
|
|
2897
|
-
if len(alias) >= 2 and alias[0] == alias[-1] and alias[0] in ['"', "'", '`']:
|
|
2898
|
-
alias = alias[1:-1].strip()
|
|
2899
|
-
elif alias and alias[-1] in ['"', "'", '`']:
|
|
2900
|
-
alias = alias[:-1].strip()
|
|
2901
|
-
else:
|
|
2902
|
-
break
|
|
2903
|
-
# Stop at first whitespace or invalid char
|
|
2904
|
-
alias = re.split(r'[^A-Za-z0-9\-_]', alias)[0]
|
|
2905
|
-
return alias
|
|
2906
|
-
|
|
2907
|
-
def extract_resume_alias(command):
|
|
2908
|
-
"""Extract resume alias safely.
|
|
2909
|
-
Priority:
|
|
2910
|
-
1) HCOM_SEND payload that starts with RESUME:alias
|
|
2911
|
-
2) Bare HCOM_RESUME:alias (only when not embedded in HCOM_SEND payload)
|
|
2912
|
-
"""
|
|
2913
|
-
# 1) Prefer explicit HCOM_SEND payload
|
|
2914
|
-
payload = extract_hcom_command(command)
|
|
2915
|
-
if payload:
|
|
2916
|
-
cand = payload.strip()
|
|
2917
|
-
if cand.startswith('RESUME:'):
|
|
2918
|
-
alias_raw = cand.split(':', 1)[1].strip()
|
|
2919
|
-
alias = _sanitize_alias(alias_raw)
|
|
2920
|
-
return alias or None
|
|
2921
|
-
# If payload contains text like "HCOM_RESUME:alias" but not at start,
|
|
2922
|
-
# ignore to prevent alias hijack from normal messages
|
|
2923
|
-
|
|
2924
|
-
# 2) Fallback: bare HCOM_RESUME when not using HCOM_SEND
|
|
2925
|
-
alias_raw = extract_hcom_command(command, 'HCOM_RESUME')
|
|
2926
|
-
if alias_raw:
|
|
2927
|
-
alias = _sanitize_alias(alias_raw)
|
|
2928
|
-
return alias or None
|
|
2929
|
-
return None
|
|
2930
|
-
|
|
2931
|
-
def compute_decision_for_visibility(transcript_path):
|
|
2932
|
-
"""Compute hook decision based on pending tools to prevent API 400 errors."""
|
|
2933
|
-
pending_tools = get_pending_tools(transcript_path)
|
|
2934
|
-
decision = None if pending_tools > 0 else HOOK_DECISION_BLOCK
|
|
2802
|
+
# Return flags indicating resume state
|
|
2803
|
+
is_resume_match = merged_state is not None
|
|
2804
|
+
return instance_name, updates, existing_data, is_resume_match, is_new_instance
|
|
2935
2805
|
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
"""Emit formatted resume feedback with appropriate visibility."""
|
|
2940
|
-
# Build formatted feedback based on success/failure
|
|
2941
|
-
if status.startswith("[SUCCESS]"):
|
|
2942
|
-
reason = f"[{status}]{HCOM_FORMAT_INSTRUCTIONS}"
|
|
2943
|
-
else:
|
|
2944
|
-
reason = f"[⚠️ {status} - your alias is: {instance_name}]{HCOM_FORMAT_INSTRUCTIONS}"
|
|
2806
|
+
def handle_pretooluse(hook_data, instance_name, updates):
|
|
2807
|
+
"""Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
|
|
2808
|
+
tool_name = hook_data.get('tool_name', '')
|
|
2945
2809
|
|
|
2946
|
-
#
|
|
2947
|
-
|
|
2810
|
+
# Non-HCOM_SEND tools: record status (they'll run without permission check)
|
|
2811
|
+
set_status(instance_name, 'tool_pending', tool_name)
|
|
2948
2812
|
|
|
2949
|
-
|
|
2950
|
-
emit_hook_response(reason, decision=decision)
|
|
2813
|
+
import time
|
|
2951
2814
|
|
|
2952
|
-
|
|
2953
|
-
"""Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
|
|
2954
|
-
# Check if this is an HCOM_SEND command that needs auto-approval
|
|
2955
|
-
tool_name = hook_data.get('tool_name', '')
|
|
2815
|
+
# Handle HCOM commands in Bash
|
|
2956
2816
|
if tool_name == 'Bash':
|
|
2957
2817
|
command = hook_data.get('tool_input', {}).get('command', '')
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
}
|
|
2973
|
-
else:
|
|
2974
|
-
# Safe to proceed
|
|
2975
|
-
output = {
|
|
2976
|
-
"hookSpecificOutput": {
|
|
2977
|
-
"hookEventName": "PreToolUse",
|
|
2978
|
-
"permissionDecision": "allow",
|
|
2979
|
-
"permissionDecisionReason": "HCOM_SEND command auto-approved"
|
|
2980
|
-
}
|
|
2818
|
+
script_path = str(Path(__file__).resolve())
|
|
2819
|
+
|
|
2820
|
+
# === Auto-approve ALL '$HCOM send' commands (including --resume) ===
|
|
2821
|
+
# This includes:
|
|
2822
|
+
# - $HCOM send "message" (normal messaging between instances)
|
|
2823
|
+
# - $HCOM send --resume alias (resume/merge operation)
|
|
2824
|
+
if ('$HCOM send' in command or
|
|
2825
|
+
'hcom send' in command or
|
|
2826
|
+
(script_path in command and ' send ' in command)):
|
|
2827
|
+
output = {
|
|
2828
|
+
"hookSpecificOutput": {
|
|
2829
|
+
"hookEventName": "PreToolUse",
|
|
2830
|
+
"permissionDecision": "allow",
|
|
2831
|
+
"permissionDecisionReason": "HCOM send command auto-approved"
|
|
2981
2832
|
}
|
|
2833
|
+
}
|
|
2982
2834
|
print(json.dumps(output, ensure_ascii=False))
|
|
2983
2835
|
sys.exit(EXIT_SUCCESS)
|
|
2984
2836
|
|
|
2985
|
-
def handle_posttooluse(hook_data, instance_name, updates):
|
|
2986
|
-
"""Handle PostToolUse hook - extract and deliver messages"""
|
|
2987
|
-
updates['last_tool'] = int(time.time())
|
|
2988
|
-
updates['last_tool_name'] = hook_data.get('tool_name', 'unknown')
|
|
2989
|
-
update_instance_position(instance_name, updates)
|
|
2990
|
-
|
|
2991
|
-
# Check for HCOM_SEND in Bash commands
|
|
2992
|
-
sent_reason = None
|
|
2993
|
-
if hook_data.get('tool_name') == 'Bash':
|
|
2994
|
-
command = hook_data.get('tool_input', {}).get('command', '')
|
|
2995
2837
|
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
if alias:
|
|
2999
|
-
status = merge_instance_immediately(instance_name, alias)
|
|
3000
|
-
|
|
3001
|
-
# If names match, find and merge any duplicate with same PID
|
|
3002
|
-
if not status and instance_name == alias:
|
|
3003
|
-
instances_dir = hcom_path(INSTANCES_DIR)
|
|
3004
|
-
parent_pid = os.getppid()
|
|
3005
|
-
if instances_dir.exists():
|
|
3006
|
-
for instance_file in instances_dir.glob("*.json"):
|
|
3007
|
-
if instance_file.stem != instance_name:
|
|
3008
|
-
try:
|
|
3009
|
-
data = load_instance_position(instance_file.stem)
|
|
3010
|
-
if data.get('pid') == parent_pid:
|
|
3011
|
-
# Found duplicate - merge it
|
|
3012
|
-
status = merge_instance_immediately(instance_file.stem, instance_name)
|
|
3013
|
-
if status:
|
|
3014
|
-
status = f"[SUCCESS] ✓ Merged duplicate: {instance_file.stem} → {instance_name}"
|
|
3015
|
-
break
|
|
3016
|
-
except:
|
|
3017
|
-
continue
|
|
3018
|
-
|
|
3019
|
-
if not status:
|
|
3020
|
-
status = f"[SUCCESS] ✓ Already using alias {alias}"
|
|
3021
|
-
elif not status:
|
|
3022
|
-
status = f"[WARNING] ⚠️ Merge failed: {instance_name} → {alias}"
|
|
3023
|
-
|
|
3024
|
-
if status:
|
|
3025
|
-
transcript_path = hook_data.get('transcript_path', '')
|
|
3026
|
-
emit_resume_feedback(status, instance_name, transcript_path)
|
|
3027
|
-
return # Don't process RESUME as regular message
|
|
3028
|
-
|
|
3029
|
-
# Normal message handling
|
|
3030
|
-
message = extract_hcom_command(command) # defaults to HCOM_SEND
|
|
3031
|
-
if message:
|
|
3032
|
-
error = validate_message(message)
|
|
3033
|
-
if error:
|
|
3034
|
-
emit_hook_response(f"❌ {error}")
|
|
3035
|
-
send_message(instance_name, message)
|
|
3036
|
-
sent_reason = "[✓ Sent]"
|
|
3037
|
-
|
|
3038
|
-
# Check for pending tools in transcript
|
|
3039
|
-
transcript_path = hook_data.get('transcript_path', '')
|
|
3040
|
-
pending_count = get_pending_tools(transcript_path)
|
|
3041
|
-
|
|
3042
|
-
# Build response if needed
|
|
3043
|
-
response_reason = None
|
|
3044
|
-
|
|
3045
|
-
# Only deliver messages when all tools are complete (pending_count == 0)
|
|
3046
|
-
if pending_count == 0:
|
|
3047
|
-
messages = get_new_messages(instance_name)
|
|
3048
|
-
if messages:
|
|
3049
|
-
messages = messages[:get_config_value('max_messages_per_delivery', 50)]
|
|
3050
|
-
reason = format_hook_messages(messages, instance_name)
|
|
3051
|
-
response_reason = f"{sent_reason} | {reason}" if sent_reason else reason
|
|
3052
|
-
elif sent_reason:
|
|
3053
|
-
response_reason = sent_reason
|
|
3054
|
-
elif sent_reason:
|
|
3055
|
-
# Tools still pending - acknowledge HCOM_SEND without disrupting tool batching
|
|
3056
|
-
response_reason = sent_reason
|
|
3057
|
-
|
|
3058
|
-
# Emit response with formatting if we have anything to say
|
|
3059
|
-
if response_reason:
|
|
3060
|
-
response_reason += HCOM_FORMAT_INSTRUCTIONS
|
|
3061
|
-
# CRITICAL: decision=None when tools are pending to prevent API 400 errors
|
|
3062
|
-
decision = compute_decision_for_visibility(transcript_path)
|
|
3063
|
-
emit_hook_response(response_reason, decision=decision)
|
|
3064
|
-
|
|
3065
|
-
def handle_stop(instance_name, updates):
|
|
3066
|
-
"""Handle Stop hook - poll for messages"""
|
|
3067
|
-
updates['last_stop'] = time.time()
|
|
3068
|
-
timeout = get_config_value('wait_timeout', 1800)
|
|
3069
|
-
updates['wait_timeout'] = timeout
|
|
3070
|
-
|
|
3071
|
-
# Try to update position, but continue on Windows file locking errors
|
|
2838
|
+
def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
|
|
2839
|
+
"""Safely exit stop hook with proper status tracking"""
|
|
3072
2840
|
try:
|
|
3073
|
-
|
|
3074
|
-
except
|
|
3075
|
-
# Silently handle
|
|
3076
|
-
|
|
2841
|
+
set_status(instance_name, 'stop_exit')
|
|
2842
|
+
except (OSError, PermissionError):
|
|
2843
|
+
pass # Silently handle any errors
|
|
2844
|
+
sys.exit(code)
|
|
3077
2845
|
|
|
3078
|
-
|
|
3079
|
-
|
|
2846
|
+
def handle_stop(hook_data, instance_name, updates):
|
|
2847
|
+
"""Handle Stop hook - poll for messages and deliver"""
|
|
2848
|
+
import time as time_module
|
|
3080
2849
|
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
loop_count += 1
|
|
3085
|
-
current_time = time.time()
|
|
2850
|
+
parent_pid = os.getppid()
|
|
2851
|
+
log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
|
|
2852
|
+
log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
|
|
3086
2853
|
|
|
3087
|
-
# Unix/Mac: Check if orphaned (reparented to PID 1)
|
|
3088
|
-
if not IS_WINDOWS and os.getppid() == 1:
|
|
3089
|
-
sys.exit(EXIT_SUCCESS)
|
|
3090
2854
|
|
|
3091
|
-
|
|
3092
|
-
|
|
2855
|
+
try:
|
|
2856
|
+
entry_time = time_module.time()
|
|
2857
|
+
updates['last_stop'] = entry_time
|
|
2858
|
+
timeout = get_config_value('wait_timeout', 1800)
|
|
2859
|
+
updates['wait_timeout'] = timeout
|
|
2860
|
+
set_status(instance_name, 'waiting')
|
|
3093
2861
|
|
|
3094
|
-
|
|
3095
|
-
|
|
2862
|
+
try:
|
|
2863
|
+
update_instance_position(instance_name, updates)
|
|
2864
|
+
except Exception as e:
|
|
2865
|
+
log_hook_error(f'stop:update_instance_position({instance_name})', e)
|
|
3096
2866
|
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
pending_count = get_pending_tools(transcript_path)
|
|
2867
|
+
start_time = time_module.time()
|
|
2868
|
+
log_hook_error(f'stop:start_time_pid_{os.getpid()}')
|
|
3100
2869
|
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
2870
|
+
try:
|
|
2871
|
+
loop_count = 0
|
|
2872
|
+
# STEP 4: Actual polling loop - this IS the holding pattern
|
|
2873
|
+
while time_module.time() - start_time < timeout:
|
|
2874
|
+
if loop_count == 0:
|
|
2875
|
+
time_module.sleep(0.1) # Initial wait before first poll
|
|
2876
|
+
loop_count += 1
|
|
2877
|
+
|
|
2878
|
+
# Check if parent is alive
|
|
2879
|
+
if not IS_WINDOWS and os.getppid() == 1:
|
|
2880
|
+
log_hook_error(f'stop:parent_died_pid_{os.getpid()}')
|
|
2881
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2882
|
+
|
|
2883
|
+
parent_alive = is_parent_alive(parent_pid)
|
|
2884
|
+
if not parent_alive:
|
|
2885
|
+
log_hook_error(f'stop:parent_not_alive_pid_{os.getpid()}')
|
|
2886
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2887
|
+
|
|
2888
|
+
# Check if user input is pending - exit cleanly if so
|
|
2889
|
+
user_input_signal = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
|
|
2890
|
+
if user_input_signal.exists():
|
|
2891
|
+
log_hook_error(f'stop:user_input_pending_exiting_pid_{os.getpid()}')
|
|
2892
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2893
|
+
|
|
2894
|
+
# Check for new messages and deliver
|
|
2895
|
+
if messages := get_unread_messages(instance_name, update_position=True):
|
|
3105
2896
|
messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
|
|
3106
2897
|
reason = format_hook_messages(messages_to_show, instance_name)
|
|
3107
|
-
|
|
2898
|
+
set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
|
|
3108
2899
|
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
#
|
|
3115
|
-
|
|
2900
|
+
log_hook_error(f'stop:delivering_message_pid_{os.getpid()}')
|
|
2901
|
+
output = {"decision": "block", "reason": reason}
|
|
2902
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
2903
|
+
sys.exit(EXIT_BLOCK)
|
|
2904
|
+
|
|
2905
|
+
# Update heartbeat
|
|
2906
|
+
try:
|
|
2907
|
+
update_instance_position(instance_name, {'last_stop': time_module.time()})
|
|
2908
|
+
# log_hook_error(f'hb_pid_{os.getpid()}')
|
|
2909
|
+
except Exception as e:
|
|
2910
|
+
log_hook_error(f'stop:heartbeat_update({instance_name})', e)
|
|
2911
|
+
|
|
2912
|
+
time_module.sleep(STOP_HOOK_POLL_INTERVAL)
|
|
3116
2913
|
|
|
3117
|
-
|
|
2914
|
+
except Exception as loop_e:
|
|
2915
|
+
# Log polling loop errors but continue to cleanup
|
|
2916
|
+
log_hook_error(f'stop:polling_loop({instance_name})', loop_e)
|
|
2917
|
+
|
|
2918
|
+
# Timeout reached
|
|
2919
|
+
set_status(instance_name, 'timeout')
|
|
3118
2920
|
|
|
3119
2921
|
except Exception as e:
|
|
3120
|
-
#
|
|
3121
|
-
|
|
2922
|
+
# Log error and exit gracefully
|
|
2923
|
+
log_hook_error('handle_stop', e)
|
|
2924
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
3122
2925
|
|
|
3123
2926
|
def handle_notify(hook_data, instance_name, updates):
|
|
3124
2927
|
"""Handle Notification hook - track permission requests"""
|
|
3125
|
-
updates['last_permission_request'] = int(time.time())
|
|
3126
2928
|
updates['notification_message'] = hook_data.get('message', '')
|
|
3127
2929
|
update_instance_position(instance_name, updates)
|
|
2930
|
+
set_status(instance_name, 'blocked', hook_data.get('message', ''))
|
|
3128
2931
|
|
|
3129
|
-
def
|
|
2932
|
+
def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
|
|
2933
|
+
"""Handle UserPromptSubmit hook - track when user sends messages"""
|
|
2934
|
+
import time as time_module
|
|
2935
|
+
|
|
2936
|
+
# Update last user input timestamp
|
|
2937
|
+
updates['last_user_input'] = time_module.time()
|
|
2938
|
+
update_instance_position(instance_name, updates)
|
|
2939
|
+
|
|
2940
|
+
# Signal any polling Stop hook to exit cleanly before user input processed
|
|
2941
|
+
signal_file = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
|
|
2942
|
+
try:
|
|
2943
|
+
log_hook_error(f'userpromptsubmit:signal_file_touched_pid_{os.getpid()}')
|
|
2944
|
+
signal_file.touch()
|
|
2945
|
+
time_module.sleep(0.15) # Give Stop hook time to detect and exit
|
|
2946
|
+
log_hook_error(f'userpromptsubmit:signal_file_unlinked_pid_{os.getpid()}')
|
|
2947
|
+
signal_file.unlink()
|
|
2948
|
+
except (OSError, PermissionError) as e:
|
|
2949
|
+
log_hook_error(f'userpromptsubmit:signal_file_error', e)
|
|
2950
|
+
|
|
2951
|
+
send_cmd = build_send_command('your message')
|
|
2952
|
+
resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
|
|
2953
|
+
# Build message based on what happened
|
|
2954
|
+
msg = None
|
|
2955
|
+
if is_resume_match:
|
|
2956
|
+
msg = f"[Resumed with hcom chat alias: {instance_name}. Use {send_cmd} to send messages]"
|
|
2957
|
+
elif is_new_instance:
|
|
2958
|
+
# Unmatched resume - show critical recovery message
|
|
2959
|
+
msg = (
|
|
2960
|
+
f"[HCOM RESUME DETECTED - CRITICAL ACTION REQUIRED "
|
|
2961
|
+
f"You MUST recover your HCOM identity to maintain conversation context "
|
|
2962
|
+
f"Run: {resume_cmd} "
|
|
2963
|
+
f"This is REQUIRED for message history and position tracking]"
|
|
2964
|
+
)
|
|
2965
|
+
else:
|
|
2966
|
+
# Check if we need to announce alias (normal startup)
|
|
2967
|
+
instance_data = load_instance_position(instance_name)
|
|
2968
|
+
alias_announced = instance_data.get('alias_announced', False)
|
|
2969
|
+
if not alias_announced:
|
|
2970
|
+
msg = f"[Your hcom chat alias is {instance_name}. You can at-mention others in hcom chat by their alias to DM them. To send a message use: {send_cmd}]"
|
|
2971
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
2972
|
+
|
|
2973
|
+
if msg:
|
|
2974
|
+
output = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": msg}}
|
|
2975
|
+
print(json.dumps(output))
|
|
2976
|
+
|
|
2977
|
+
def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
|
|
3130
2978
|
"""Handle SessionStart hook - deliver welcome/resume message"""
|
|
3131
2979
|
source = hook_data.get('source', 'startup')
|
|
3132
2980
|
|
|
2981
|
+
log_hook_error(f'sessionstart:is_resume_match_{is_resume_match}')
|
|
2982
|
+
log_hook_error(f'sessionstart:instance_name_{instance_name}')
|
|
2983
|
+
log_hook_error(f'sessionstart:source_{source}')
|
|
2984
|
+
log_hook_error(f'sessionstart:updates_{updates}')
|
|
2985
|
+
log_hook_error(f'sessionstart:hook_data_{hook_data}')
|
|
2986
|
+
|
|
3133
2987
|
# Reset alias_announced flag so alias shows again on resume/clear/compact
|
|
3134
2988
|
updates['alias_announced'] = False
|
|
3135
2989
|
|
|
3136
|
-
#
|
|
3137
|
-
|
|
2990
|
+
# Only update instance position if file exists (startup or matched resume)
|
|
2991
|
+
# For unmatched resumes, skip - UserPromptSubmit will create the file with correct session_id
|
|
2992
|
+
if source == 'startup' or is_resume_match:
|
|
2993
|
+
update_instance_position(instance_name, updates)
|
|
2994
|
+
set_status(instance_name, 'session_start')
|
|
2995
|
+
|
|
2996
|
+
log_hook_error(f'sessionstart:instance_name_after_update_{instance_name}')
|
|
2997
|
+
|
|
2998
|
+
# Build send command using helper
|
|
2999
|
+
send_cmd = build_send_command('your message')
|
|
3000
|
+
help_text = f"[Welcome! HCOM chat active. Send messages: {send_cmd}]"
|
|
3138
3001
|
|
|
3139
3002
|
# Add subagent type if this is a named agent
|
|
3140
3003
|
subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
|
|
@@ -3147,11 +3010,10 @@ def handle_sessionstart(hook_data, instance_name, updates):
|
|
|
3147
3010
|
if first_use_text:
|
|
3148
3011
|
help_text += f" [{first_use_text}]"
|
|
3149
3012
|
elif source == 'resume':
|
|
3150
|
-
if
|
|
3151
|
-
|
|
3152
|
-
help_text += f" [⚠️ Resume detected - temp: {instance_name}. If you had a previous HCOM alias, run: echo \"HCOM_RESUME:your_alias\"]"
|
|
3013
|
+
if is_resume_match:
|
|
3014
|
+
help_text += f" [Resumed alias: {instance_name}]"
|
|
3153
3015
|
else:
|
|
3154
|
-
help_text += " [
|
|
3016
|
+
help_text += f" [Session resumed]"
|
|
3155
3017
|
|
|
3156
3018
|
# Add instance hints to all messages
|
|
3157
3019
|
instance_hints = get_config_value('instance_hints', '')
|
|
@@ -3167,37 +3029,34 @@ def handle_sessionstart(hook_data, instance_name, updates):
|
|
|
3167
3029
|
}
|
|
3168
3030
|
print(json.dumps(output))
|
|
3169
3031
|
|
|
3170
|
-
|
|
3171
|
-
update_instance_position(instance_name, updates)
|
|
3172
|
-
|
|
3173
|
-
def handle_hook(hook_type):
|
|
3032
|
+
def handle_hook(hook_type: str) -> None:
|
|
3174
3033
|
"""Unified hook handler for all HCOM hooks"""
|
|
3175
3034
|
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
3176
3035
|
sys.exit(EXIT_SUCCESS)
|
|
3177
3036
|
|
|
3178
|
-
|
|
3179
|
-
|
|
3037
|
+
hook_data = json.load(sys.stdin)
|
|
3038
|
+
log_hook_error(f'handle_hook:hook_data_{hook_data}')
|
|
3180
3039
|
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3040
|
+
# DEBUG: Log which hook is being called with which session_id
|
|
3041
|
+
session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
|
|
3042
|
+
log_hook_error(f'DEBUG: Hook {hook_type} called with session_id={session_id_short}')
|
|
3043
|
+
|
|
3044
|
+
instance_name, updates, _, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
|
|
3045
|
+
|
|
3046
|
+
match hook_type:
|
|
3047
|
+
case 'pre':
|
|
3048
|
+
handle_pretooluse(hook_data, instance_name, updates)
|
|
3049
|
+
case 'stop':
|
|
3050
|
+
handle_stop(hook_data, instance_name, updates)
|
|
3051
|
+
case 'notify':
|
|
3052
|
+
handle_notify(hook_data, instance_name, updates)
|
|
3053
|
+
case 'userpromptsubmit':
|
|
3054
|
+
handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
|
|
3055
|
+
case 'sessionstart':
|
|
3056
|
+
handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
|
|
3057
|
+
|
|
3058
|
+
log_hook_error(f'handle_hook:instance_name_{instance_name}')
|
|
3198
3059
|
|
|
3199
|
-
except Exception:
|
|
3200
|
-
pass
|
|
3201
3060
|
|
|
3202
3061
|
sys.exit(EXIT_SUCCESS)
|
|
3203
3062
|
|
|
@@ -3214,34 +3073,41 @@ def main(argv=None):
|
|
|
3214
3073
|
|
|
3215
3074
|
cmd = argv[1]
|
|
3216
3075
|
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3076
|
+
match cmd:
|
|
3077
|
+
case 'help' | '--help':
|
|
3078
|
+
return cmd_help()
|
|
3079
|
+
case 'open':
|
|
3080
|
+
return cmd_open(*argv[2:])
|
|
3081
|
+
case 'watch':
|
|
3082
|
+
return cmd_watch(*argv[2:])
|
|
3083
|
+
case 'clear':
|
|
3084
|
+
return cmd_clear()
|
|
3085
|
+
case 'cleanup':
|
|
3086
|
+
return cmd_cleanup(*argv[2:])
|
|
3087
|
+
case 'send':
|
|
3088
|
+
if len(argv) < 3:
|
|
3089
|
+
print(format_error("Message required"), file=sys.stderr)
|
|
3090
|
+
return 1
|
|
3091
|
+
|
|
3092
|
+
# HIDDEN COMMAND: --resume is only used internally by instances during resume workflow
|
|
3093
|
+
# Not meant for regular CLI usage. Primary usage:
|
|
3094
|
+
# - From instances: $HCOM send "message" (instances send messages to each other)
|
|
3095
|
+
# - From CLI: hcom send "message" (user/claude orchestrator sends to instances)
|
|
3096
|
+
if argv[2] == '--resume':
|
|
3097
|
+
if len(argv) < 4:
|
|
3098
|
+
print(format_error("Alias required for --resume"), file=sys.stderr)
|
|
3099
|
+
return 1
|
|
3100
|
+
return cmd_resume_merge(argv[3])
|
|
3101
|
+
|
|
3102
|
+
return cmd_send(argv[2])
|
|
3103
|
+
case 'kill':
|
|
3104
|
+
return cmd_kill(*argv[2:])
|
|
3105
|
+
case 'stop' | 'notify' | 'pre' | 'sessionstart' | 'userpromptsubmit':
|
|
3106
|
+
handle_hook(cmd)
|
|
3107
|
+
return 0
|
|
3108
|
+
case _:
|
|
3109
|
+
print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
|
|
3231
3110
|
return 1
|
|
3232
|
-
return cmd_send(argv[2])
|
|
3233
|
-
elif cmd == 'kill':
|
|
3234
|
-
return cmd_kill(*argv[2:])
|
|
3235
|
-
|
|
3236
|
-
# Hook commands
|
|
3237
|
-
elif cmd in ['post', 'stop', 'notify', 'pre', 'sessionstart']:
|
|
3238
|
-
handle_hook(cmd)
|
|
3239
|
-
return 0
|
|
3240
|
-
|
|
3241
|
-
# Unknown command
|
|
3242
|
-
else:
|
|
3243
|
-
print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
|
|
3244
|
-
return 1
|
|
3245
3111
|
|
|
3246
3112
|
if __name__ == '__main__':
|
|
3247
3113
|
sys.exit(main())
|