hcom 0.2.3__py3-none-any.whl → 0.3.1__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 +660 -812
- {hcom-0.2.3.dist-info → hcom-0.3.1.dist-info}/METADATA +11 -11
- hcom-0.3.1.dist-info/RECORD +7 -0
- hcom-0.2.3.dist-info/RECORD +0 -7
- {hcom-0.2.3.dist-info → hcom-0.3.1.dist-info}/WHEEL +0 -0
- {hcom-0.2.3.dist-info → hcom-0.3.1.dist-info}/entry_points.txt +0 -0
- {hcom-0.2.3.dist-info → hcom-0.3.1.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.1
|
|
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', '
|
|
1329
|
+
for hook_type in ['SessionStart', 'UserPromptSubmit', '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,42 @@ def format_age(seconds):
|
|
|
1499
1507
|
else:
|
|
1500
1508
|
return f"{int(seconds/3600)}h"
|
|
1501
1509
|
|
|
1502
|
-
def
|
|
1503
|
-
"""
|
|
1504
|
-
|
|
1505
|
-
|
|
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:]
|
|
1510
|
+
def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str, str]:
|
|
1511
|
+
"""Get current status of instance. Returns (status_type, age_string, description)."""
|
|
1512
|
+
# Returns: (display_category, formatted_age, status_description)
|
|
1513
|
+
now = int(time.time())
|
|
1517
1514
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
|
1515
|
+
# Check if killed
|
|
1516
|
+
if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
|
|
1517
|
+
return "inactive", "", "killed"
|
|
1543
1518
|
|
|
1544
|
-
|
|
1519
|
+
# Get last known status
|
|
1520
|
+
last_status = pos_data.get('last_status', '')
|
|
1521
|
+
last_status_time = pos_data.get('last_status_time', 0)
|
|
1522
|
+
last_context = pos_data.get('last_status_context', '')
|
|
1545
1523
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
transcript_path,
|
|
1549
|
-
read_status,
|
|
1550
|
-
default=("inactive", "", "", 0)
|
|
1551
|
-
)
|
|
1552
|
-
return result
|
|
1553
|
-
except Exception:
|
|
1554
|
-
return "inactive", "", "", 0
|
|
1524
|
+
if not last_status or not last_status_time:
|
|
1525
|
+
return "unknown", "", "unknown"
|
|
1555
1526
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
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
|
-
|
|
1585
|
-
# Calculate last actual activity (excluding heartbeat)
|
|
1586
|
-
last_activity = max(last_permission, last_tool, transcript_timestamp)
|
|
1587
|
-
|
|
1588
|
-
# Check timeout based on actual activity
|
|
1589
|
-
if last_activity > 0 and (now - last_activity) > wait_timeout:
|
|
1590
|
-
return "inactive", ""
|
|
1591
|
-
|
|
1592
|
-
# Now determine current status including heartbeat
|
|
1593
|
-
events = [
|
|
1594
|
-
(last_permission, "blocked"),
|
|
1595
|
-
(last_stop, "waiting"),
|
|
1596
|
-
(last_tool, "inactive"),
|
|
1597
|
-
(transcript_timestamp, transcript_status)
|
|
1598
|
-
]
|
|
1527
|
+
# Get display category and description template from STATUS_INFO
|
|
1528
|
+
display_status, desc_template = STATUS_INFO.get(last_status, ('unknown', 'unknown'))
|
|
1599
1529
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1530
|
+
# Check timeout
|
|
1531
|
+
age = now - last_status_time
|
|
1532
|
+
timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
|
|
1533
|
+
if age > timeout:
|
|
1534
|
+
return "inactive", "", "timeout"
|
|
1603
1535
|
|
|
1604
|
-
|
|
1605
|
-
|
|
1536
|
+
# Format description with context if template has {}
|
|
1537
|
+
if '{}' in desc_template and last_context:
|
|
1538
|
+
status_desc = desc_template.format(last_context)
|
|
1539
|
+
else:
|
|
1540
|
+
status_desc = desc_template
|
|
1606
1541
|
|
|
1607
1542
|
status_suffix = " (bg)" if pos_data.get('background') else ""
|
|
1608
|
-
|
|
1543
|
+
return display_status, f"({format_age(age)}){status_suffix}", status_desc
|
|
1609
1544
|
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
def get_status_block(status_type):
|
|
1545
|
+
def get_status_block(status_type: str) -> str:
|
|
1613
1546
|
"""Get colored status block for a status type"""
|
|
1614
1547
|
color, symbol = STATUS_MAP.get(status_type, (BG_RED, "?"))
|
|
1615
1548
|
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
@@ -1660,7 +1593,7 @@ def show_recent_activity_alt_screen(limit=None):
|
|
|
1660
1593
|
|
|
1661
1594
|
log_file = hcom_path(LOG_FILE)
|
|
1662
1595
|
if log_file.exists():
|
|
1663
|
-
messages = parse_log_messages(log_file)
|
|
1596
|
+
messages = parse_log_messages(log_file).messages
|
|
1664
1597
|
show_recent_messages(messages, limit, truncate=True)
|
|
1665
1598
|
|
|
1666
1599
|
def show_instances_by_directory():
|
|
@@ -1681,13 +1614,10 @@ def show_instances_by_directory():
|
|
|
1681
1614
|
for directory, instances in directories.items():
|
|
1682
1615
|
print(f" {directory}")
|
|
1683
1616
|
for instance_name, pos_data in instances:
|
|
1684
|
-
status_type, age = get_instance_status(pos_data)
|
|
1617
|
+
status_type, age, status_desc = get_instance_status(pos_data)
|
|
1685
1618
|
status_block = get_status_block(status_type)
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
|
|
1689
|
-
|
|
1690
|
-
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{RESET}")
|
|
1619
|
+
|
|
1620
|
+
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_desc} {age}{RESET}")
|
|
1691
1621
|
print()
|
|
1692
1622
|
else:
|
|
1693
1623
|
print(f" {DIM}Error reading instance data{RESET}")
|
|
@@ -1727,15 +1657,15 @@ def get_status_summary():
|
|
|
1727
1657
|
if not positions:
|
|
1728
1658
|
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1729
1659
|
|
|
1730
|
-
status_counts = {
|
|
1660
|
+
status_counts = {status: 0 for status in STATUS_MAP.keys()}
|
|
1731
1661
|
|
|
1732
|
-
for
|
|
1733
|
-
status_type, _ = get_instance_status(pos_data)
|
|
1662
|
+
for _, pos_data in positions.items():
|
|
1663
|
+
status_type, _, _ = get_instance_status(pos_data)
|
|
1734
1664
|
if status_type in status_counts:
|
|
1735
1665
|
status_counts[status_type] += 1
|
|
1736
1666
|
|
|
1737
1667
|
parts = []
|
|
1738
|
-
status_order = ["
|
|
1668
|
+
status_order = ["active", "delivered", "waiting", "blocked", "inactive", "unknown"]
|
|
1739
1669
|
|
|
1740
1670
|
for status_type in status_order:
|
|
1741
1671
|
count = status_counts[status_type]
|
|
@@ -1770,11 +1700,8 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
|
|
|
1770
1700
|
defaults = {
|
|
1771
1701
|
"pos": 0,
|
|
1772
1702
|
"directory": str(Path.cwd()),
|
|
1773
|
-
"last_tool": 0,
|
|
1774
|
-
"last_tool_name": "unknown",
|
|
1775
1703
|
"last_stop": 0,
|
|
1776
|
-
"
|
|
1777
|
-
"session_ids": [session_id] if session_id else [],
|
|
1704
|
+
"session_id": session_id or "",
|
|
1778
1705
|
"transcript_path": "",
|
|
1779
1706
|
"notification_message": "",
|
|
1780
1707
|
"alias_announced": False
|
|
@@ -1807,12 +1734,19 @@ def update_instance_position(instance_name, update_fields):
|
|
|
1807
1734
|
else:
|
|
1808
1735
|
raise
|
|
1809
1736
|
|
|
1737
|
+
def set_status(instance_name: str, status: str, context: str = ''):
|
|
1738
|
+
"""Set instance status event with timestamp"""
|
|
1739
|
+
update_instance_position(instance_name, {
|
|
1740
|
+
'last_status': status,
|
|
1741
|
+
'last_status_time': int(time.time()),
|
|
1742
|
+
'last_status_context': context
|
|
1743
|
+
})
|
|
1744
|
+
log_hook_error(f"Set status for {instance_name} to {status} with context {context}") #TODO: change to 'log'?
|
|
1745
|
+
|
|
1810
1746
|
def merge_instance_data(to_data, from_data):
|
|
1811
1747
|
"""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))
|
|
1748
|
+
# Use current session_id from source (overwrites previous)
|
|
1749
|
+
to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
|
|
1816
1750
|
|
|
1817
1751
|
# Update transient fields from source
|
|
1818
1752
|
to_data['pid'] = os.getppid() # Always use current PID
|
|
@@ -1824,14 +1758,16 @@ def merge_instance_data(to_data, from_data):
|
|
|
1824
1758
|
# Update directory to most recent
|
|
1825
1759
|
to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
|
|
1826
1760
|
|
|
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'))
|
|
1761
|
+
# Update heartbeat timestamp to most recent
|
|
1830
1762
|
to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
)
|
|
1763
|
+
|
|
1764
|
+
# Merge new status fields - take most recent status event
|
|
1765
|
+
from_time = from_data.get('last_status_time', 0)
|
|
1766
|
+
to_time = to_data.get('last_status_time', 0)
|
|
1767
|
+
if from_time > to_time:
|
|
1768
|
+
to_data['last_status'] = from_data.get('last_status', '')
|
|
1769
|
+
to_data['last_status_time'] = from_time
|
|
1770
|
+
to_data['last_status_context'] = from_data.get('last_status_context', '')
|
|
1835
1771
|
|
|
1836
1772
|
# Preserve background mode if set
|
|
1837
1773
|
to_data['background'] = to_data.get('background') or from_data.get('background')
|
|
@@ -1863,11 +1799,15 @@ def merge_instance_immediately(from_name, to_name):
|
|
|
1863
1799
|
from_data = load_instance_position(from_name)
|
|
1864
1800
|
to_data = load_instance_position(to_name)
|
|
1865
1801
|
|
|
1866
|
-
# Check if target
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1802
|
+
# Check if target has recent activity (time-based check instead of PID)
|
|
1803
|
+
now = time.time()
|
|
1804
|
+
last_activity = max(
|
|
1805
|
+
to_data.get('last_stop', 0),
|
|
1806
|
+
to_data.get('last_status_time', 0)
|
|
1807
|
+
)
|
|
1808
|
+
time_since_activity = now - last_activity
|
|
1809
|
+
if time_since_activity < MERGE_ACTIVITY_THRESHOLD:
|
|
1810
|
+
return f"Cannot recover {to_name}: instance is active (activity {int(time_since_activity)}s ago)"
|
|
1871
1811
|
|
|
1872
1812
|
# Merge data using helper
|
|
1873
1813
|
to_data = merge_instance_data(to_data, from_data)
|
|
@@ -1879,12 +1819,12 @@ def merge_instance_immediately(from_name, to_name):
|
|
|
1879
1819
|
# Cleanup source file only after successful save
|
|
1880
1820
|
try:
|
|
1881
1821
|
hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
|
|
1882
|
-
except:
|
|
1822
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
1883
1823
|
pass # Non-critical if cleanup fails
|
|
1884
1824
|
|
|
1885
|
-
return f"[SUCCESS] ✓ Recovered: {
|
|
1825
|
+
return f"[SUCCESS] ✓ Recovered alias: {to_name}"
|
|
1886
1826
|
except Exception:
|
|
1887
|
-
return f"Failed to
|
|
1827
|
+
return f"Failed to recover alias: {to_name}"
|
|
1888
1828
|
|
|
1889
1829
|
|
|
1890
1830
|
# ==================== Command Functions ====================
|
|
@@ -1896,9 +1836,8 @@ def show_main_screen_header():
|
|
|
1896
1836
|
log_file = hcom_path(LOG_FILE)
|
|
1897
1837
|
all_messages = []
|
|
1898
1838
|
if log_file.exists():
|
|
1899
|
-
all_messages = parse_log_messages(log_file)
|
|
1900
|
-
|
|
1901
|
-
|
|
1839
|
+
all_messages = parse_log_messages(log_file).messages
|
|
1840
|
+
|
|
1902
1841
|
print(f"{BOLD}HCOM{RESET} LOGS")
|
|
1903
1842
|
print(f"{DIM}{'─'*40}{RESET}\n")
|
|
1904
1843
|
|
|
@@ -1947,13 +1886,15 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
|
|
|
1947
1886
|
|
|
1948
1887
|
=== ADDITIONAL INFO ===
|
|
1949
1888
|
|
|
1950
|
-
CONCEPT: HCOM
|
|
1951
|
-
|
|
1889
|
+
CONCEPT: HCOM launches Claude Code instances in new terminal windows.
|
|
1890
|
+
They communicate with each other via a shared conversation.
|
|
1891
|
+
You communicate with them via hcom automation commands.
|
|
1952
1892
|
|
|
1953
1893
|
KEY UNDERSTANDING:
|
|
1954
1894
|
• Single conversation - All instances share ~/.hcom/hcom.log
|
|
1955
|
-
•
|
|
1956
|
-
•
|
|
1895
|
+
• Messaging - Use 'hcom send "message"' from CLI to send messages to instances
|
|
1896
|
+
• Instances receive messages via hooks automatically and send with 'eval $HCOM send "message"'
|
|
1897
|
+
• hcom open is directory-specific - always cd to project directory first
|
|
1957
1898
|
• hcom watch --wait outputs existing logs, then waits for the next message, prints it, and exits.
|
|
1958
1899
|
Times out after [seconds]
|
|
1959
1900
|
• Named agents are custom system prompts created by users/claude code.
|
|
@@ -1961,7 +1902,7 @@ Times out after [seconds]
|
|
|
1961
1902
|
|
|
1962
1903
|
LAUNCH PATTERNS:
|
|
1963
1904
|
hcom open 2 reviewer # 2 generic + 1 reviewer agent
|
|
1964
|
-
hcom open reviewer reviewer # 2 separate reviewer instances
|
|
1905
|
+
hcom open reviewer reviewer # 2 separate reviewer instances
|
|
1965
1906
|
hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
|
|
1966
1907
|
hcom open --claude-args "--model sonnet" # Pass 'claude' CLI flags
|
|
1967
1908
|
hcom open --background (or -p) then hcom kill # Detached background process
|
|
@@ -1975,10 +1916,12 @@ LAUNCH PATTERNS:
|
|
|
1975
1916
|
(Unmatched @mentions broadcast to everyone)
|
|
1976
1917
|
|
|
1977
1918
|
STATUS INDICATORS:
|
|
1978
|
-
•
|
|
1919
|
+
• ▶ active - instance is working (processing/executing)
|
|
1920
|
+
• ▷ delivered - instance just received a message
|
|
1979
1921
|
• ◉ waiting - instance is waiting for new messages
|
|
1980
1922
|
• ■ blocked - instance is blocked by permission request (needs user approval)
|
|
1981
1923
|
• ○ inactive - instance is timed out, disconnected, etc
|
|
1924
|
+
• ○ unknown - no status information available
|
|
1982
1925
|
|
|
1983
1926
|
CONFIG:
|
|
1984
1927
|
Config file (persistent): ~/.hcom/config.json
|
|
@@ -2011,14 +1954,6 @@ def cmd_open(*args):
|
|
|
2011
1954
|
# Parse arguments
|
|
2012
1955
|
instances, prefix, claude_args, background = parse_open_args(list(args))
|
|
2013
1956
|
|
|
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
1957
|
# Add -p flag and stream-json output for background mode if not already present
|
|
2023
1958
|
if background and '-p' not in claude_args and '--print' not in claude_args:
|
|
2024
1959
|
claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
|
|
@@ -2049,32 +1984,25 @@ def cmd_open(*args):
|
|
|
2049
1984
|
# Build environment variables for Claude instances
|
|
2050
1985
|
base_env = build_claude_env()
|
|
2051
1986
|
|
|
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
1987
|
# Add prefix-specific hints if provided
|
|
2060
1988
|
if prefix:
|
|
2061
1989
|
base_env['HCOM_PREFIX'] = prefix
|
|
2062
|
-
|
|
1990
|
+
send_cmd = build_send_command()
|
|
1991
|
+
hint = f"To respond to {prefix} group: {send_cmd} '@{prefix} message'"
|
|
2063
1992
|
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."
|
|
1993
|
+
first_use = f"You're in the {prefix} group. Use {prefix} to message: {send_cmd} '@{prefix} message'"
|
|
2066
1994
|
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
2067
1995
|
|
|
2068
1996
|
launched = 0
|
|
2069
1997
|
initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
|
|
2070
1998
|
|
|
2071
|
-
for
|
|
1999
|
+
for _, instance_type in enumerate(instances):
|
|
2072
2000
|
instance_env = base_env.copy()
|
|
2073
2001
|
|
|
2074
|
-
#
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2002
|
+
# Set unique launch ID for sender detection in cmd_send()
|
|
2003
|
+
launch_id = f"{int(time.time())}_{random.randint(10000, 99999)}"
|
|
2004
|
+
instance_env['HCOM_LAUNCH_ID'] = launch_id
|
|
2005
|
+
|
|
2078
2006
|
# Mark background instances via environment with log filename
|
|
2079
2007
|
if background:
|
|
2080
2008
|
# Generate unique log filename
|
|
@@ -2084,7 +2012,7 @@ def cmd_open(*args):
|
|
|
2084
2012
|
# Build claude command
|
|
2085
2013
|
if instance_type == 'generic':
|
|
2086
2014
|
# Generic instance - no agent content
|
|
2087
|
-
claude_cmd,
|
|
2015
|
+
claude_cmd, _ = build_claude_command(
|
|
2088
2016
|
agent_content=None,
|
|
2089
2017
|
claude_args=claude_args,
|
|
2090
2018
|
initial_prompt=initial_prompt
|
|
@@ -2101,7 +2029,7 @@ def cmd_open(*args):
|
|
|
2101
2029
|
# Use agent's model and tools if specified and not overridden in claude_args
|
|
2102
2030
|
agent_model = agent_config.get('model')
|
|
2103
2031
|
agent_tools = agent_config.get('tools')
|
|
2104
|
-
claude_cmd,
|
|
2032
|
+
claude_cmd, _ = build_claude_command(
|
|
2105
2033
|
agent_content=agent_content,
|
|
2106
2034
|
claude_args=claude_args,
|
|
2107
2035
|
initial_prompt=initial_prompt,
|
|
@@ -2217,7 +2145,7 @@ def cmd_watch(*args):
|
|
|
2217
2145
|
# Atomic position capture BEFORE parsing (prevents race condition)
|
|
2218
2146
|
if log_file.exists():
|
|
2219
2147
|
last_pos = log_file.stat().st_size # Capture position first
|
|
2220
|
-
messages = parse_log_messages(log_file)
|
|
2148
|
+
messages = parse_log_messages(log_file).messages
|
|
2221
2149
|
else:
|
|
2222
2150
|
last_pos = 0
|
|
2223
2151
|
messages = []
|
|
@@ -2229,7 +2157,7 @@ def cmd_watch(*args):
|
|
|
2229
2157
|
|
|
2230
2158
|
# Status to stderr, data to stdout
|
|
2231
2159
|
if recent_messages:
|
|
2232
|
-
print(f'---Showing last 5 seconds of messages---', file=sys.stderr)
|
|
2160
|
+
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
2161
|
for msg in recent_messages:
|
|
2234
2162
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
2235
2163
|
print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
|
|
@@ -2245,7 +2173,7 @@ def cmd_watch(*args):
|
|
|
2245
2173
|
new_messages = []
|
|
2246
2174
|
if current_size > last_pos:
|
|
2247
2175
|
# Capture new position BEFORE parsing (atomic)
|
|
2248
|
-
new_messages = parse_log_messages(log_file, last_pos)
|
|
2176
|
+
new_messages = parse_log_messages(log_file, last_pos).messages
|
|
2249
2177
|
if new_messages:
|
|
2250
2178
|
for msg in new_messages:
|
|
2251
2179
|
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
@@ -2277,14 +2205,15 @@ def cmd_watch(*args):
|
|
|
2277
2205
|
status_counts = {}
|
|
2278
2206
|
|
|
2279
2207
|
for name, data in positions.items():
|
|
2280
|
-
status, age = get_instance_status(data)
|
|
2208
|
+
status, age, _ = get_instance_status(data)
|
|
2281
2209
|
instances[name] = {
|
|
2282
2210
|
"status": status,
|
|
2283
2211
|
"age": age.strip() if age else "",
|
|
2284
2212
|
"directory": data.get("directory", "unknown"),
|
|
2285
|
-
"
|
|
2286
|
-
"
|
|
2287
|
-
"
|
|
2213
|
+
"session_id": data.get("session_id", ""),
|
|
2214
|
+
"last_status": data.get("last_status", ""),
|
|
2215
|
+
"last_status_time": data.get("last_status_time", 0),
|
|
2216
|
+
"last_status_context": data.get("last_status_context", ""),
|
|
2288
2217
|
"pid": data.get("pid"),
|
|
2289
2218
|
"background": bool(data.get("background"))
|
|
2290
2219
|
}
|
|
@@ -2293,7 +2222,7 @@ def cmd_watch(*args):
|
|
|
2293
2222
|
# Get recent messages
|
|
2294
2223
|
messages = []
|
|
2295
2224
|
if log_file.exists():
|
|
2296
|
-
all_messages = parse_log_messages(log_file)
|
|
2225
|
+
all_messages = parse_log_messages(log_file).messages
|
|
2297
2226
|
messages = all_messages[-5:] if all_messages else []
|
|
2298
2227
|
|
|
2299
2228
|
# Output JSON
|
|
@@ -2313,6 +2242,7 @@ def cmd_watch(*args):
|
|
|
2313
2242
|
print(" hcom watch --logs Show message history", file=sys.stderr)
|
|
2314
2243
|
print(" hcom watch --status Show instance status", file=sys.stderr)
|
|
2315
2244
|
print(" hcom watch --wait Wait for new messages", file=sys.stderr)
|
|
2245
|
+
print(" Full information: hcom --help")
|
|
2316
2246
|
|
|
2317
2247
|
show_cli_hints()
|
|
2318
2248
|
|
|
@@ -2357,7 +2287,7 @@ def cmd_watch(*args):
|
|
|
2357
2287
|
if log_file.exists():
|
|
2358
2288
|
current_size = log_file.stat().st_size
|
|
2359
2289
|
if current_size > last_pos:
|
|
2360
|
-
new_messages = parse_log_messages(log_file, last_pos)
|
|
2290
|
+
new_messages = parse_log_messages(log_file, last_pos).messages
|
|
2361
2291
|
# Use the last known status for consistency
|
|
2362
2292
|
status_line_text = f"{last_status}{status_suffix}"
|
|
2363
2293
|
for msg in new_messages:
|
|
@@ -2367,9 +2297,9 @@ def cmd_watch(*args):
|
|
|
2367
2297
|
# Check for keyboard input
|
|
2368
2298
|
ready_for_input = False
|
|
2369
2299
|
if IS_WINDOWS:
|
|
2370
|
-
import msvcrt
|
|
2371
|
-
if msvcrt.kbhit():
|
|
2372
|
-
msvcrt.getch()
|
|
2300
|
+
import msvcrt # type: ignore[import]
|
|
2301
|
+
if msvcrt.kbhit(): # type: ignore[attr-defined]
|
|
2302
|
+
msvcrt.getch() # type: ignore[attr-defined]
|
|
2373
2303
|
ready_for_input = True
|
|
2374
2304
|
else:
|
|
2375
2305
|
if select.select([sys.stdin], [], [], 0.1)[0]:
|
|
@@ -2428,6 +2358,13 @@ def cmd_clear():
|
|
|
2428
2358
|
if script_count > 0:
|
|
2429
2359
|
print(f"Cleaned up {script_count} old script files")
|
|
2430
2360
|
|
|
2361
|
+
# Clean up old launch mapping files (older than 24 hours)
|
|
2362
|
+
if instances_dir.exists():
|
|
2363
|
+
cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
|
|
2364
|
+
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)
|
|
2365
|
+
if mapping_count > 0:
|
|
2366
|
+
print(f"Cleaned up {mapping_count} old launch mapping files")
|
|
2367
|
+
|
|
2431
2368
|
# Check if hcom files exist
|
|
2432
2369
|
if not log_file.exists() and not instances_dir.exists():
|
|
2433
2370
|
print("No hcom conversation to clear")
|
|
@@ -2458,11 +2395,15 @@ def cmd_clear():
|
|
|
2458
2395
|
if has_instances:
|
|
2459
2396
|
archive_instances = session_archive / INSTANCES_DIR
|
|
2460
2397
|
archive_instances.mkdir(exist_ok=True)
|
|
2461
|
-
|
|
2398
|
+
|
|
2462
2399
|
# Move json files only
|
|
2463
2400
|
for f in instances_dir.glob('*.json'):
|
|
2464
2401
|
f.rename(archive_instances / f.name)
|
|
2465
|
-
|
|
2402
|
+
|
|
2403
|
+
# Clean up orphaned mapping files (position files are archived)
|
|
2404
|
+
for f in instances_dir.glob('.launch_map_*'):
|
|
2405
|
+
f.unlink(missing_ok=True)
|
|
2406
|
+
|
|
2466
2407
|
archived = True
|
|
2467
2408
|
else:
|
|
2468
2409
|
# Clean up empty files/dirs
|
|
@@ -2506,14 +2447,15 @@ def cleanup_directory_hooks(directory):
|
|
|
2506
2447
|
|
|
2507
2448
|
hooks_found = False
|
|
2508
2449
|
|
|
2450
|
+
# Include PostToolUse for backward compatibility cleanup
|
|
2509
2451
|
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2510
|
-
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2511
|
-
|
|
2452
|
+
for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2453
|
+
|
|
2512
2454
|
_remove_hcom_hooks_from_settings(settings)
|
|
2513
|
-
|
|
2455
|
+
|
|
2514
2456
|
# Check if any were removed
|
|
2515
2457
|
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
2516
|
-
for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2458
|
+
for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
|
|
2517
2459
|
if new_hook_count < original_hook_count:
|
|
2518
2460
|
hooks_found = True
|
|
2519
2461
|
|
|
@@ -2551,7 +2493,7 @@ def cmd_kill(*args):
|
|
|
2551
2493
|
|
|
2552
2494
|
killed_count = 0
|
|
2553
2495
|
for target_name, target_data in targets:
|
|
2554
|
-
status, age = get_instance_status(target_data)
|
|
2496
|
+
status, age, _ = get_instance_status(target_data)
|
|
2555
2497
|
instance_type = "background" if target_data.get('background') else "foreground"
|
|
2556
2498
|
|
|
2557
2499
|
pid = int(target_data['pid'])
|
|
@@ -2577,6 +2519,7 @@ def cmd_kill(*args):
|
|
|
2577
2519
|
|
|
2578
2520
|
# Mark instance as killed
|
|
2579
2521
|
update_instance_position(target_name, {'pid': None})
|
|
2522
|
+
set_status(target_name, 'killed')
|
|
2580
2523
|
|
|
2581
2524
|
if not instance_name:
|
|
2582
2525
|
print(f"Killed {killed_count} instance(s)")
|
|
@@ -2645,35 +2588,60 @@ def cmd_send(message):
|
|
|
2645
2588
|
# Check if hcom files exist
|
|
2646
2589
|
log_file = hcom_path(LOG_FILE)
|
|
2647
2590
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2648
|
-
|
|
2591
|
+
|
|
2649
2592
|
if not log_file.exists() and not instances_dir.exists():
|
|
2650
2593
|
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
2651
2594
|
return 1
|
|
2652
|
-
|
|
2595
|
+
|
|
2653
2596
|
# Validate message
|
|
2654
2597
|
error = validate_message(message)
|
|
2655
2598
|
if error:
|
|
2656
2599
|
print(error, file=sys.stderr)
|
|
2657
2600
|
return 1
|
|
2658
|
-
|
|
2601
|
+
|
|
2659
2602
|
# Check for unmatched mentions (minimal warning)
|
|
2660
2603
|
mentions = MENTION_PATTERN.findall(message)
|
|
2661
2604
|
if mentions:
|
|
2662
2605
|
try:
|
|
2663
2606
|
positions = load_all_positions()
|
|
2664
2607
|
all_instances = list(positions.keys())
|
|
2665
|
-
|
|
2666
|
-
|
|
2608
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
2609
|
+
all_names = all_instances + [sender_name]
|
|
2610
|
+
unmatched = [m for m in mentions
|
|
2611
|
+
if not any(name.lower().startswith(m.lower()) for name in all_names)]
|
|
2667
2612
|
if unmatched:
|
|
2668
2613
|
print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
|
|
2669
2614
|
except Exception:
|
|
2670
2615
|
pass # Don't fail on warning
|
|
2671
|
-
|
|
2672
|
-
#
|
|
2673
|
-
sender_name =
|
|
2674
|
-
|
|
2616
|
+
|
|
2617
|
+
# Determine sender: lookup by launch_id, fallback to config
|
|
2618
|
+
sender_name = None
|
|
2619
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2620
|
+
if launch_id:
|
|
2621
|
+
try:
|
|
2622
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
|
|
2623
|
+
if mapping_file.exists():
|
|
2624
|
+
sender_name = mapping_file.read_text(encoding='utf-8').strip()
|
|
2625
|
+
except Exception:
|
|
2626
|
+
pass
|
|
2627
|
+
|
|
2628
|
+
if not sender_name:
|
|
2629
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
2630
|
+
|
|
2675
2631
|
if send_message(sender_name, message):
|
|
2676
|
-
|
|
2632
|
+
# For instances: check for new messages and display immediately
|
|
2633
|
+
if launch_id: # Only for instances with HCOM_LAUNCH_ID
|
|
2634
|
+
messages = get_unread_messages(sender_name, update_position=True)
|
|
2635
|
+
if messages:
|
|
2636
|
+
max_msgs = get_config_value('max_messages_per_delivery', 50)
|
|
2637
|
+
messages_to_show = messages[:max_msgs]
|
|
2638
|
+
formatted = format_hook_messages(messages_to_show, sender_name)
|
|
2639
|
+
print(f"Message sent\n\n{formatted}", file=sys.stderr)
|
|
2640
|
+
else:
|
|
2641
|
+
print("Message sent", file=sys.stderr)
|
|
2642
|
+
else:
|
|
2643
|
+
# Bigboss: just confirm send
|
|
2644
|
+
print("Message sent", file=sys.stderr)
|
|
2677
2645
|
|
|
2678
2646
|
# Show cli_hints if configured (non-interactive mode)
|
|
2679
2647
|
if not is_interactive():
|
|
@@ -2684,6 +2652,49 @@ def cmd_send(message):
|
|
|
2684
2652
|
print(format_error("Failed to send message"), file=sys.stderr)
|
|
2685
2653
|
return 1
|
|
2686
2654
|
|
|
2655
|
+
def cmd_resume_merge(alias: str) -> int:
|
|
2656
|
+
"""Resume/merge current instance into an existing instance by alias.
|
|
2657
|
+
|
|
2658
|
+
INTERNAL COMMAND: Only called via 'eval $HCOM send --resume alias' during implicit resume workflow.
|
|
2659
|
+
Not meant for direct CLI usage.
|
|
2660
|
+
"""
|
|
2661
|
+
# Get current instance name via launch_id mapping (same mechanism as cmd_send)
|
|
2662
|
+
# The mapping is created by init_hook_context() when hooks run
|
|
2663
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2664
|
+
if not launch_id:
|
|
2665
|
+
print(format_error("Not in HCOM instance context - no launch ID"), file=sys.stderr)
|
|
2666
|
+
return 1
|
|
2667
|
+
|
|
2668
|
+
instance_name = None
|
|
2669
|
+
try:
|
|
2670
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
|
|
2671
|
+
if mapping_file.exists():
|
|
2672
|
+
instance_name = mapping_file.read_text(encoding='utf-8').strip()
|
|
2673
|
+
except Exception:
|
|
2674
|
+
pass
|
|
2675
|
+
|
|
2676
|
+
if not instance_name:
|
|
2677
|
+
print(format_error("Could not determine instance name"), file=sys.stderr)
|
|
2678
|
+
return 1
|
|
2679
|
+
|
|
2680
|
+
# Sanitize alias: only allow alphanumeric, dash, underscore
|
|
2681
|
+
# This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
|
|
2682
|
+
if not re.match(r'^[A-Za-z0-9\-_]+$', alias):
|
|
2683
|
+
print(format_error("Invalid alias format. Use alphanumeric, dash, or underscore only"), file=sys.stderr)
|
|
2684
|
+
return 1
|
|
2685
|
+
|
|
2686
|
+
# Attempt to merge current instance into target alias
|
|
2687
|
+
status = merge_instance_immediately(instance_name, alias)
|
|
2688
|
+
|
|
2689
|
+
# Handle results
|
|
2690
|
+
if not status:
|
|
2691
|
+
# Empty status means names matched (from_name == to_name)
|
|
2692
|
+
status = f"[SUCCESS] ✓ Already using alias {alias}"
|
|
2693
|
+
|
|
2694
|
+
# Print status and return
|
|
2695
|
+
print(status, file=sys.stderr)
|
|
2696
|
+
return 0 if status.startswith('[SUCCESS]') else 1
|
|
2697
|
+
|
|
2687
2698
|
# ==================== Hook Helpers ====================
|
|
2688
2699
|
|
|
2689
2700
|
def format_hook_messages(messages, instance_name):
|
|
@@ -2693,13 +2704,7 @@ def format_hook_messages(messages, instance_name):
|
|
|
2693
2704
|
reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
|
|
2694
2705
|
else:
|
|
2695
2706
|
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})
|
|
2707
|
+
reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
|
|
2703
2708
|
|
|
2704
2709
|
# Only append instance_hints to messages (first_use_text is handled separately)
|
|
2705
2710
|
instance_hints = get_config_value('instance_hints', '')
|
|
@@ -2708,149 +2713,73 @@ def format_hook_messages(messages, instance_name):
|
|
|
2708
2713
|
|
|
2709
2714
|
return reason
|
|
2710
2715
|
|
|
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
2716
|
# ==================== Hook Handlers ====================
|
|
2766
2717
|
|
|
2767
|
-
def init_hook_context(hook_data):
|
|
2718
|
+
def init_hook_context(hook_data, hook_type=None):
|
|
2768
2719
|
"""Initialize instance context - shared by post/stop/notify hooks"""
|
|
2769
2720
|
session_id = hook_data.get('session_id', '')
|
|
2770
2721
|
transcript_path = hook_data.get('transcript_path', '')
|
|
2771
2722
|
prefix = os.environ.get('HCOM_PREFIX')
|
|
2772
2723
|
|
|
2773
|
-
# Check if this is a resume operation
|
|
2774
|
-
resume_session_id = os.environ.get('HCOM_RESUME_SESSION_ID')
|
|
2775
2724
|
instances_dir = hcom_path(INSTANCES_DIR)
|
|
2776
2725
|
instance_name = None
|
|
2777
2726
|
merged_state = None
|
|
2778
2727
|
|
|
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
|
|
2728
|
+
# Check if current session_id matches any existing instance
|
|
2729
|
+
# This maintains identity after resume/merge operations
|
|
2801
2730
|
if not instance_name and session_id and instances_dir.exists():
|
|
2802
2731
|
for instance_file in instances_dir.glob("*.json"):
|
|
2803
2732
|
try:
|
|
2804
2733
|
data = load_instance_position(instance_file.stem)
|
|
2805
|
-
if session_id
|
|
2734
|
+
if session_id == data.get('session_id'):
|
|
2806
2735
|
instance_name = instance_file.stem
|
|
2807
2736
|
merged_state = data
|
|
2737
|
+
log_hook_error(f'DEBUG: Session_id {session_id[:8]} matched {instance_file.stem}, reusing that name')
|
|
2808
2738
|
break
|
|
2809
|
-
except:
|
|
2739
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
2810
2740
|
continue
|
|
2811
2741
|
|
|
2812
2742
|
# If not found or not resuming, generate new name from session_id
|
|
2813
2743
|
if not instance_name:
|
|
2814
2744
|
instance_name = get_display_name(session_id, prefix)
|
|
2745
|
+
# DEBUG: Log name generation
|
|
2746
|
+
log_hook_error(f'DEBUG: Generated instance_name={instance_name} from session_id={session_id[:8] if session_id else "None"}')
|
|
2815
2747
|
|
|
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
|
|
2748
|
+
# Save launch_id → instance_name mapping for cmd_send()
|
|
2749
|
+
launch_id = os.environ.get('HCOM_LAUNCH_ID')
|
|
2750
|
+
if launch_id:
|
|
2751
|
+
try:
|
|
2752
|
+
mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}', ensure_parent=True)
|
|
2753
|
+
mapping_file.write_text(instance_name, encoding='utf-8')
|
|
2754
|
+
log_hook_error(f'DEBUG: FINAL - Wrote launch_map_{launch_id} → {instance_name} (session_id={session_id[:8] if session_id else "None"})')
|
|
2755
|
+
except Exception:
|
|
2756
|
+
pass # Non-critical
|
|
2835
2757
|
|
|
2836
2758
|
# Save migrated data if we have it
|
|
2837
2759
|
if merged_state:
|
|
2838
2760
|
save_instance_position(instance_name, merged_state)
|
|
2839
2761
|
|
|
2840
|
-
|
|
2841
|
-
|
|
2762
|
+
# Check if instance is brand new or pre-existing (before creation (WWJD))
|
|
2763
|
+
instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
|
|
2764
|
+
is_new_instance = not instance_file.exists()
|
|
2842
2765
|
|
|
2843
|
-
#
|
|
2844
|
-
|
|
2766
|
+
# Skip instance creation for unmatched SessionStart resumes (prevents orphans)
|
|
2767
|
+
# Instance will be created in UserPromptSubmit with correct session_id
|
|
2768
|
+
should_create_instance = not (
|
|
2769
|
+
hook_type == 'sessionstart' and
|
|
2770
|
+
hook_data.get('source', 'startup') == 'resume' and not merged_state
|
|
2771
|
+
)
|
|
2772
|
+
if should_create_instance:
|
|
2773
|
+
initialize_instance_in_position_file(instance_name, session_id)
|
|
2774
|
+
|
|
2775
|
+
# Prepare updates
|
|
2776
|
+
updates: dict[str, Any] = {
|
|
2845
2777
|
'directory': str(Path.cwd()),
|
|
2846
2778
|
}
|
|
2847
2779
|
|
|
2848
|
-
# Update
|
|
2780
|
+
# Update session_id (overwrites previous)
|
|
2849
2781
|
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
|
|
2782
|
+
updates['session_id'] = session_id
|
|
2854
2783
|
|
|
2855
2784
|
# Update transcript_path to current
|
|
2856
2785
|
if transcript_path:
|
|
@@ -2865,276 +2794,192 @@ def init_hook_context(hook_data):
|
|
|
2865
2794
|
updates['background'] = True
|
|
2866
2795
|
updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
|
|
2867
2796
|
|
|
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
|
|
2935
|
-
|
|
2936
|
-
return decision
|
|
2937
|
-
|
|
2938
|
-
def emit_resume_feedback(status, instance_name, transcript_path):
|
|
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}"
|
|
2797
|
+
# Return flags indicating resume state
|
|
2798
|
+
is_resume_match = merged_state is not None
|
|
2799
|
+
return instance_name, updates, is_resume_match, is_new_instance
|
|
2945
2800
|
|
|
2946
|
-
|
|
2947
|
-
decision = compute_decision_for_visibility(transcript_path)
|
|
2948
|
-
|
|
2949
|
-
# Emit response
|
|
2950
|
-
emit_hook_response(reason, decision=decision)
|
|
2951
|
-
|
|
2952
|
-
def handle_pretooluse(hook_data):
|
|
2801
|
+
def handle_pretooluse(hook_data, instance_name, updates):
|
|
2953
2802
|
"""Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
|
|
2954
|
-
# Check if this is an HCOM_SEND command that needs auto-approval
|
|
2955
2803
|
tool_name = hook_data.get('tool_name', '')
|
|
2804
|
+
|
|
2805
|
+
# Non-HCOM_SEND tools: record status (they'll run without permission check)
|
|
2806
|
+
set_status(instance_name, 'tool_pending', tool_name)
|
|
2807
|
+
|
|
2808
|
+
# Handle HCOM commands in Bash
|
|
2956
2809
|
if tool_name == 'Bash':
|
|
2957
2810
|
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
|
-
}
|
|
2811
|
+
script_path = str(Path(__file__).resolve())
|
|
2812
|
+
|
|
2813
|
+
# === Auto-approve ALL 'eval $HCOM send' commands (including --resume) ===
|
|
2814
|
+
# This includes:
|
|
2815
|
+
# - eval $HCOM send "message" (normal messaging between instances)
|
|
2816
|
+
# - eval $HCOM send --resume alias (resume/merge operation)
|
|
2817
|
+
if ('$HCOM send' in command or
|
|
2818
|
+
'hcom send' in command or
|
|
2819
|
+
(script_path in command and ' send ' in command)):
|
|
2820
|
+
output = {
|
|
2821
|
+
"hookSpecificOutput": {
|
|
2822
|
+
"hookEventName": "PreToolUse",
|
|
2823
|
+
"permissionDecision": "allow",
|
|
2824
|
+
"permissionDecisionReason": "HCOM send command auto-approved"
|
|
2981
2825
|
}
|
|
2826
|
+
}
|
|
2982
2827
|
print(json.dumps(output, ensure_ascii=False))
|
|
2983
2828
|
sys.exit(EXIT_SUCCESS)
|
|
2984
2829
|
|
|
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
2830
|
|
|
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
|
|
2831
|
+
def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
|
|
2832
|
+
"""Safely exit stop hook with proper status tracking"""
|
|
3072
2833
|
try:
|
|
3073
|
-
|
|
3074
|
-
except
|
|
3075
|
-
# Silently handle
|
|
3076
|
-
|
|
2834
|
+
set_status(instance_name, 'stop_exit')
|
|
2835
|
+
except (OSError, PermissionError):
|
|
2836
|
+
pass # Silently handle any errors
|
|
2837
|
+
sys.exit(code)
|
|
3077
2838
|
|
|
2839
|
+
def handle_stop(hook_data, instance_name, updates):
|
|
2840
|
+
"""Handle Stop hook - poll for messages and deliver"""
|
|
3078
2841
|
parent_pid = os.getppid()
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
try:
|
|
3082
|
-
loop_count = 0
|
|
3083
|
-
while time.time() - start_time < timeout:
|
|
3084
|
-
loop_count += 1
|
|
3085
|
-
current_time = time.time()
|
|
2842
|
+
log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
|
|
2843
|
+
log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
|
|
3086
2844
|
|
|
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
2845
|
|
|
3091
|
-
|
|
3092
|
-
|
|
2846
|
+
try:
|
|
2847
|
+
entry_time = time.time()
|
|
2848
|
+
updates['last_stop'] = entry_time
|
|
2849
|
+
timeout = get_config_value('wait_timeout', 1800)
|
|
2850
|
+
updates['wait_timeout'] = timeout
|
|
2851
|
+
set_status(instance_name, 'waiting')
|
|
3093
2852
|
|
|
3094
|
-
|
|
3095
|
-
|
|
2853
|
+
try:
|
|
2854
|
+
update_instance_position(instance_name, updates)
|
|
2855
|
+
except Exception as e:
|
|
2856
|
+
log_hook_error(f'stop:update_instance_position({instance_name})', e)
|
|
3096
2857
|
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
pending_count = get_pending_tools(transcript_path)
|
|
2858
|
+
start_time = time.time()
|
|
2859
|
+
log_hook_error(f'stop:start_time_pid_{os.getpid()}')
|
|
3100
2860
|
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
2861
|
+
try:
|
|
2862
|
+
loop_count = 0
|
|
2863
|
+
last_heartbeat = start_time
|
|
2864
|
+
# STEP 4: Actual polling loop - this IS the holding pattern
|
|
2865
|
+
while time.time() - start_time < timeout:
|
|
2866
|
+
if loop_count == 0:
|
|
2867
|
+
time.sleep(0.1) # Initial wait before first poll
|
|
2868
|
+
loop_count += 1
|
|
2869
|
+
|
|
2870
|
+
# Check if parent is alive
|
|
2871
|
+
if not is_parent_alive(parent_pid):
|
|
2872
|
+
log_hook_error(f'stop:parent_not_alive_pid_{os.getpid()}')
|
|
2873
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2874
|
+
|
|
2875
|
+
# Load instance data once per poll (needed for messages and user input check)
|
|
2876
|
+
instance_data = load_instance_position(instance_name)
|
|
2877
|
+
|
|
2878
|
+
# Check if user input is pending - exit cleanly if recent input
|
|
2879
|
+
last_user_input = instance_data.get('last_user_input', 0)
|
|
2880
|
+
if time.time() - last_user_input < 0.2:
|
|
2881
|
+
log_hook_error(f'stop:user_input_pending_exiting_pid_{os.getpid()}')
|
|
2882
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
2883
|
+
|
|
2884
|
+
# Check for new messages and deliver
|
|
2885
|
+
if messages := get_unread_messages(instance_name, update_position=True):
|
|
3105
2886
|
messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
|
|
3106
2887
|
reason = format_hook_messages(messages_to_show, instance_name)
|
|
3107
|
-
|
|
2888
|
+
set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
|
|
3108
2889
|
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
#
|
|
3115
|
-
|
|
2890
|
+
log_hook_error(f'stop:delivering_message_pid_{os.getpid()}')
|
|
2891
|
+
output = {"decision": "block", "reason": reason}
|
|
2892
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
2893
|
+
sys.exit(EXIT_BLOCK)
|
|
2894
|
+
|
|
2895
|
+
# Update heartbeat every 5 seconds instead of every poll
|
|
2896
|
+
now = time.time()
|
|
2897
|
+
if now - last_heartbeat >= 5.0:
|
|
2898
|
+
try:
|
|
2899
|
+
update_instance_position(instance_name, {'last_stop': now})
|
|
2900
|
+
last_heartbeat = now
|
|
2901
|
+
except Exception as e:
|
|
2902
|
+
log_hook_error(f'stop:heartbeat_update({instance_name})', e)
|
|
2903
|
+
|
|
2904
|
+
time.sleep(STOP_HOOK_POLL_INTERVAL)
|
|
3116
2905
|
|
|
3117
|
-
|
|
2906
|
+
except Exception as loop_e:
|
|
2907
|
+
# Log polling loop errors but continue to cleanup
|
|
2908
|
+
log_hook_error(f'stop:polling_loop({instance_name})', loop_e)
|
|
2909
|
+
|
|
2910
|
+
# Timeout reached
|
|
2911
|
+
set_status(instance_name, 'timeout')
|
|
3118
2912
|
|
|
3119
2913
|
except Exception as e:
|
|
3120
|
-
#
|
|
3121
|
-
|
|
2914
|
+
# Log error and exit gracefully
|
|
2915
|
+
log_hook_error('handle_stop', e)
|
|
2916
|
+
safe_exit_with_status(instance_name, EXIT_SUCCESS)
|
|
3122
2917
|
|
|
3123
2918
|
def handle_notify(hook_data, instance_name, updates):
|
|
3124
2919
|
"""Handle Notification hook - track permission requests"""
|
|
3125
|
-
updates['last_permission_request'] = int(time.time())
|
|
3126
2920
|
updates['notification_message'] = hook_data.get('message', '')
|
|
3127
2921
|
update_instance_position(instance_name, updates)
|
|
2922
|
+
set_status(instance_name, 'blocked', hook_data.get('message', ''))
|
|
2923
|
+
|
|
2924
|
+
def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
|
|
2925
|
+
"""Handle UserPromptSubmit hook - track when user sends messages"""
|
|
2926
|
+
# Update last user input timestamp
|
|
2927
|
+
updates['last_user_input'] = time.time()
|
|
2928
|
+
update_instance_position(instance_name, updates)
|
|
3128
2929
|
|
|
3129
|
-
|
|
2930
|
+
# Wait for Stop hook to detect timestamp and exit (prevents api errors / race condition)
|
|
2931
|
+
time.sleep(0.15)
|
|
2932
|
+
|
|
2933
|
+
send_cmd = build_send_command('your message')
|
|
2934
|
+
resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
|
|
2935
|
+
# Build message based on what happened
|
|
2936
|
+
msg = None
|
|
2937
|
+
if is_resume_match:
|
|
2938
|
+
msg = f"[Resumed with hcom chat alias: {instance_name}. Use {send_cmd} to send messages]"
|
|
2939
|
+
elif is_new_instance:
|
|
2940
|
+
# Unmatched resume - show critical recovery message
|
|
2941
|
+
msg = (
|
|
2942
|
+
f"[HCOM RESUME DETECTED - CRITICAL ACTION REQUIRED "
|
|
2943
|
+
f"You MUST recover your HCOM identity to maintain conversation context "
|
|
2944
|
+
f"Run: {resume_cmd} "
|
|
2945
|
+
f"This is REQUIRED for message history and position tracking]"
|
|
2946
|
+
)
|
|
2947
|
+
else:
|
|
2948
|
+
# Check if we need to announce alias (normal startup)
|
|
2949
|
+
instance_data = load_instance_position(instance_name)
|
|
2950
|
+
alias_announced = instance_data.get('alias_announced', False)
|
|
2951
|
+
if not alias_announced:
|
|
2952
|
+
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}]"
|
|
2953
|
+
update_instance_position(instance_name, {'alias_announced': True})
|
|
2954
|
+
|
|
2955
|
+
if msg:
|
|
2956
|
+
output = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": msg}}
|
|
2957
|
+
print(json.dumps(output))
|
|
2958
|
+
|
|
2959
|
+
def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
|
|
3130
2960
|
"""Handle SessionStart hook - deliver welcome/resume message"""
|
|
3131
2961
|
source = hook_data.get('source', 'startup')
|
|
3132
2962
|
|
|
2963
|
+
log_hook_error(f'sessionstart:is_resume_match_{is_resume_match}')
|
|
2964
|
+
log_hook_error(f'sessionstart:instance_name_{instance_name}')
|
|
2965
|
+
log_hook_error(f'sessionstart:source_{source}')
|
|
2966
|
+
log_hook_error(f'sessionstart:updates_{updates}')
|
|
2967
|
+
log_hook_error(f'sessionstart:hook_data_{hook_data}')
|
|
2968
|
+
|
|
3133
2969
|
# Reset alias_announced flag so alias shows again on resume/clear/compact
|
|
3134
2970
|
updates['alias_announced'] = False
|
|
3135
2971
|
|
|
3136
|
-
#
|
|
3137
|
-
|
|
2972
|
+
# Only update instance position if file exists (startup or matched resume)
|
|
2973
|
+
# For unmatched resumes, skip - UserPromptSubmit will create the file with correct session_id
|
|
2974
|
+
if source == 'startup' or is_resume_match:
|
|
2975
|
+
update_instance_position(instance_name, updates)
|
|
2976
|
+
set_status(instance_name, 'session_start')
|
|
2977
|
+
|
|
2978
|
+
log_hook_error(f'sessionstart:instance_name_after_update_{instance_name}')
|
|
2979
|
+
|
|
2980
|
+
# Build send command using helper
|
|
2981
|
+
send_cmd = build_send_command('your message')
|
|
2982
|
+
help_text = f"[Welcome! HCOM chat active. Send messages: {send_cmd}]"
|
|
3138
2983
|
|
|
3139
2984
|
# Add subagent type if this is a named agent
|
|
3140
2985
|
subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
|
|
@@ -3147,11 +2992,10 @@ def handle_sessionstart(hook_data, instance_name, updates):
|
|
|
3147
2992
|
if first_use_text:
|
|
3148
2993
|
help_text += f" [{first_use_text}]"
|
|
3149
2994
|
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\"]"
|
|
2995
|
+
if is_resume_match:
|
|
2996
|
+
help_text += f" [Resumed alias: {instance_name}]"
|
|
3153
2997
|
else:
|
|
3154
|
-
help_text += " [
|
|
2998
|
+
help_text += f" [Session resumed]"
|
|
3155
2999
|
|
|
3156
3000
|
# Add instance hints to all messages
|
|
3157
3001
|
instance_hints = get_config_value('instance_hints', '')
|
|
@@ -3167,37 +3011,34 @@ def handle_sessionstart(hook_data, instance_name, updates):
|
|
|
3167
3011
|
}
|
|
3168
3012
|
print(json.dumps(output))
|
|
3169
3013
|
|
|
3170
|
-
|
|
3171
|
-
update_instance_position(instance_name, updates)
|
|
3172
|
-
|
|
3173
|
-
def handle_hook(hook_type):
|
|
3014
|
+
def handle_hook(hook_type: str) -> None:
|
|
3174
3015
|
"""Unified hook handler for all HCOM hooks"""
|
|
3175
3016
|
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
3176
3017
|
sys.exit(EXIT_SUCCESS)
|
|
3177
3018
|
|
|
3178
|
-
|
|
3179
|
-
|
|
3019
|
+
hook_data = json.load(sys.stdin)
|
|
3020
|
+
log_hook_error(f'handle_hook:hook_data_{hook_data}')
|
|
3180
3021
|
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3022
|
+
# DEBUG: Log which hook is being called with which session_id
|
|
3023
|
+
session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
|
|
3024
|
+
log_hook_error(f'DEBUG: Hook {hook_type} called with session_id={session_id_short}')
|
|
3025
|
+
|
|
3026
|
+
instance_name, updates, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
|
|
3027
|
+
|
|
3028
|
+
match hook_type:
|
|
3029
|
+
case 'pre':
|
|
3030
|
+
handle_pretooluse(hook_data, instance_name, updates)
|
|
3031
|
+
case 'stop':
|
|
3032
|
+
handle_stop(hook_data, instance_name, updates)
|
|
3033
|
+
case 'notify':
|
|
3034
|
+
handle_notify(hook_data, instance_name, updates)
|
|
3035
|
+
case 'userpromptsubmit':
|
|
3036
|
+
handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
|
|
3037
|
+
case 'sessionstart':
|
|
3038
|
+
handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
|
|
3039
|
+
|
|
3040
|
+
log_hook_error(f'handle_hook:instance_name_{instance_name}')
|
|
3198
3041
|
|
|
3199
|
-
except Exception:
|
|
3200
|
-
pass
|
|
3201
3042
|
|
|
3202
3043
|
sys.exit(EXIT_SUCCESS)
|
|
3203
3044
|
|
|
@@ -3214,34 +3055,41 @@ def main(argv=None):
|
|
|
3214
3055
|
|
|
3215
3056
|
cmd = argv[1]
|
|
3216
3057
|
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3058
|
+
match cmd:
|
|
3059
|
+
case 'help' | '--help':
|
|
3060
|
+
return cmd_help()
|
|
3061
|
+
case 'open':
|
|
3062
|
+
return cmd_open(*argv[2:])
|
|
3063
|
+
case 'watch':
|
|
3064
|
+
return cmd_watch(*argv[2:])
|
|
3065
|
+
case 'clear':
|
|
3066
|
+
return cmd_clear()
|
|
3067
|
+
case 'cleanup':
|
|
3068
|
+
return cmd_cleanup(*argv[2:])
|
|
3069
|
+
case 'send':
|
|
3070
|
+
if len(argv) < 3:
|
|
3071
|
+
print(format_error("Message required"), file=sys.stderr)
|
|
3072
|
+
return 1
|
|
3073
|
+
|
|
3074
|
+
# HIDDEN COMMAND: --resume is only used internally by instances during resume workflow
|
|
3075
|
+
# Not meant for regular CLI usage. Primary usage:
|
|
3076
|
+
# - From instances: eval $HCOM send "message" (instances send messages to each other)
|
|
3077
|
+
# - From CLI: hcom send "message" (user/claude orchestrator sends to instances)
|
|
3078
|
+
if argv[2] == '--resume':
|
|
3079
|
+
if len(argv) < 4:
|
|
3080
|
+
print(format_error("Alias required for --resume"), file=sys.stderr)
|
|
3081
|
+
return 1
|
|
3082
|
+
return cmd_resume_merge(argv[3])
|
|
3083
|
+
|
|
3084
|
+
return cmd_send(argv[2])
|
|
3085
|
+
case 'kill':
|
|
3086
|
+
return cmd_kill(*argv[2:])
|
|
3087
|
+
case 'stop' | 'notify' | 'pre' | 'sessionstart' | 'userpromptsubmit':
|
|
3088
|
+
handle_hook(cmd)
|
|
3089
|
+
return 0
|
|
3090
|
+
case _:
|
|
3091
|
+
print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
|
|
3231
3092
|
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
3093
|
|
|
3246
3094
|
if __name__ == '__main__':
|
|
3247
3095
|
sys.exit(main())
|