hcom 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hcom might be problematic. Click here for more details.
- hcom/__init__.py +3 -0
- hcom/__main__.py +1917 -0
- hcom-0.1.0.dist-info/METADATA +363 -0
- hcom-0.1.0.dist-info/RECORD +7 -0
- hcom-0.1.0.dist-info/WHEEL +5 -0
- hcom-0.1.0.dist-info/entry_points.txt +2 -0
- hcom-0.1.0.dist-info/top_level.txt +1 -0
hcom/__main__.py
ADDED
|
@@ -0,0 +1,1917 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claude Hook Comms
|
|
4
|
+
A lightweight multi-agent communication and launching system for Claude instances
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import tempfile
|
|
11
|
+
import shutil
|
|
12
|
+
import shlex
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
import select
|
|
16
|
+
import threading
|
|
17
|
+
import platform
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
# ==================== Constants ====================
|
|
22
|
+
|
|
23
|
+
IS_WINDOWS = sys.platform == 'win32'
|
|
24
|
+
|
|
25
|
+
HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
|
|
26
|
+
HCOM_ACTIVE_VALUE = '1'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
EXIT_SUCCESS = 0
|
|
30
|
+
EXIT_ERROR = 1
|
|
31
|
+
EXIT_BLOCK = 2
|
|
32
|
+
|
|
33
|
+
HOOK_DECISION_BLOCK = 'block'
|
|
34
|
+
|
|
35
|
+
MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
|
|
36
|
+
TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
|
|
37
|
+
|
|
38
|
+
RESET = "\033[0m"
|
|
39
|
+
DIM = "\033[2m"
|
|
40
|
+
BOLD = "\033[1m"
|
|
41
|
+
FG_BLUE = "\033[34m"
|
|
42
|
+
FG_GREEN = "\033[32m"
|
|
43
|
+
FG_CYAN = "\033[36m"
|
|
44
|
+
FG_RED = "\033[31m"
|
|
45
|
+
FG_WHITE = "\033[37m"
|
|
46
|
+
FG_BLACK = "\033[30m"
|
|
47
|
+
BG_BLUE = "\033[44m"
|
|
48
|
+
BG_GREEN = "\033[42m"
|
|
49
|
+
BG_CYAN = "\033[46m"
|
|
50
|
+
BG_YELLOW = "\033[43m"
|
|
51
|
+
BG_RED = "\033[41m"
|
|
52
|
+
|
|
53
|
+
STATUS_MAP = {
|
|
54
|
+
"thinking": (BG_CYAN, "◉"),
|
|
55
|
+
"responding": (BG_GREEN, "▷"),
|
|
56
|
+
"executing": (BG_GREEN, "▶"),
|
|
57
|
+
"waiting": (BG_BLUE, "◉"),
|
|
58
|
+
"blocked": (BG_YELLOW, "■"),
|
|
59
|
+
"inactive": (BG_RED, "○")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# ==================== Configuration ====================
|
|
63
|
+
|
|
64
|
+
DEFAULT_CONFIG = {
|
|
65
|
+
"terminal_command": None,
|
|
66
|
+
"terminal_mode": "new_window",
|
|
67
|
+
"initial_prompt": "Say hi in chat",
|
|
68
|
+
"sender_name": "bigboss",
|
|
69
|
+
"sender_emoji": "🐳",
|
|
70
|
+
"cli_hints": "",
|
|
71
|
+
"wait_timeout": 600,
|
|
72
|
+
"max_message_size": 4096,
|
|
73
|
+
"max_messages_per_delivery": 20,
|
|
74
|
+
"first_use_text": "Essential, concise messages only, say hi in hcom chat now",
|
|
75
|
+
"instance_hints": "",
|
|
76
|
+
"env_overrides": {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_config = None
|
|
80
|
+
|
|
81
|
+
HOOK_SETTINGS = {
|
|
82
|
+
'wait_timeout': 'HCOM_WAIT_TIMEOUT',
|
|
83
|
+
'max_message_size': 'HCOM_MAX_MESSAGE_SIZE',
|
|
84
|
+
'max_messages_per_delivery': 'HCOM_MAX_MESSAGES_PER_DELIVERY',
|
|
85
|
+
'first_use_text': 'HCOM_FIRST_USE_TEXT',
|
|
86
|
+
'instance_hints': 'HCOM_INSTANCE_HINTS',
|
|
87
|
+
'sender_name': 'HCOM_SENDER_NAME',
|
|
88
|
+
'sender_emoji': 'HCOM_SENDER_EMOJI',
|
|
89
|
+
'cli_hints': 'HCOM_CLI_HINTS',
|
|
90
|
+
'terminal_mode': 'HCOM_TERMINAL_MODE',
|
|
91
|
+
'terminal_command': 'HCOM_TERMINAL_COMMAND',
|
|
92
|
+
'initial_prompt': 'HCOM_INITIAL_PROMPT'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# ==================== File System Utilities ====================
|
|
96
|
+
|
|
97
|
+
def get_hcom_dir():
|
|
98
|
+
"""Get the hcom directory in user's home"""
|
|
99
|
+
return Path.home() / ".hcom"
|
|
100
|
+
|
|
101
|
+
def ensure_hcom_dir():
|
|
102
|
+
"""Create the hcom directory if it doesn't exist"""
|
|
103
|
+
hcom_dir = get_hcom_dir()
|
|
104
|
+
hcom_dir.mkdir(exist_ok=True)
|
|
105
|
+
return hcom_dir
|
|
106
|
+
|
|
107
|
+
def atomic_write(filepath, content):
|
|
108
|
+
"""Write content to file atomically to prevent corruption"""
|
|
109
|
+
filepath = Path(filepath)
|
|
110
|
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
|
|
111
|
+
tmp.write(content)
|
|
112
|
+
tmp.flush()
|
|
113
|
+
os.fsync(tmp.fileno())
|
|
114
|
+
|
|
115
|
+
os.replace(tmp.name, filepath)
|
|
116
|
+
|
|
117
|
+
# ==================== Configuration System ====================
|
|
118
|
+
|
|
119
|
+
def get_cached_config():
|
|
120
|
+
"""Get cached configuration, loading if needed"""
|
|
121
|
+
global _config
|
|
122
|
+
if _config is None:
|
|
123
|
+
_config = _load_config_from_file()
|
|
124
|
+
return _config
|
|
125
|
+
|
|
126
|
+
def _load_config_from_file():
|
|
127
|
+
"""Actually load configuration from ~/.hcom/config.json"""
|
|
128
|
+
ensure_hcom_dir()
|
|
129
|
+
config_path = get_hcom_dir() / 'config.json'
|
|
130
|
+
|
|
131
|
+
config = DEFAULT_CONFIG.copy()
|
|
132
|
+
config['env_overrides'] = DEFAULT_CONFIG['env_overrides'].copy()
|
|
133
|
+
|
|
134
|
+
if config_path.exists():
|
|
135
|
+
try:
|
|
136
|
+
with open(config_path, 'r') as f:
|
|
137
|
+
user_config = json.load(f)
|
|
138
|
+
|
|
139
|
+
for key, value in user_config.items():
|
|
140
|
+
if key == 'env_overrides':
|
|
141
|
+
config['env_overrides'].update(value)
|
|
142
|
+
else:
|
|
143
|
+
config[key] = value
|
|
144
|
+
|
|
145
|
+
except json.JSONDecodeError:
|
|
146
|
+
print(format_warning("Invalid JSON in config file, using defaults"), file=sys.stderr)
|
|
147
|
+
else:
|
|
148
|
+
atomic_write(config_path, json.dumps(DEFAULT_CONFIG, indent=2))
|
|
149
|
+
|
|
150
|
+
return config
|
|
151
|
+
|
|
152
|
+
def get_config_value(key, default=None):
|
|
153
|
+
"""Get config value with proper precedence:
|
|
154
|
+
1. Environment variable (if in HOOK_SETTINGS)
|
|
155
|
+
2. Config file
|
|
156
|
+
3. Default value
|
|
157
|
+
"""
|
|
158
|
+
if key in HOOK_SETTINGS:
|
|
159
|
+
env_var = HOOK_SETTINGS[key]
|
|
160
|
+
env_value = os.environ.get(env_var)
|
|
161
|
+
if env_value is not None:
|
|
162
|
+
if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
|
|
163
|
+
try:
|
|
164
|
+
return int(env_value)
|
|
165
|
+
except ValueError:
|
|
166
|
+
pass
|
|
167
|
+
else:
|
|
168
|
+
return env_value
|
|
169
|
+
|
|
170
|
+
config = get_cached_config()
|
|
171
|
+
return config.get(key, default)
|
|
172
|
+
|
|
173
|
+
def build_claude_env():
|
|
174
|
+
"""Build environment variables for Claude instances"""
|
|
175
|
+
env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
|
|
176
|
+
|
|
177
|
+
config = get_cached_config()
|
|
178
|
+
for config_key, env_var in HOOK_SETTINGS.items():
|
|
179
|
+
if config_key in config:
|
|
180
|
+
config_value = config[config_key]
|
|
181
|
+
default_value = DEFAULT_CONFIG.get(config_key)
|
|
182
|
+
if config_value != default_value:
|
|
183
|
+
env[env_var] = str(config_value)
|
|
184
|
+
|
|
185
|
+
env.update(config.get('env_overrides', {}))
|
|
186
|
+
|
|
187
|
+
return env
|
|
188
|
+
|
|
189
|
+
# ==================== Message System ====================
|
|
190
|
+
|
|
191
|
+
def validate_message(message):
|
|
192
|
+
"""Validate message size and content"""
|
|
193
|
+
if not message or not message.strip():
|
|
194
|
+
return format_error("Message required")
|
|
195
|
+
|
|
196
|
+
max_size = get_config_value('max_message_size', 4096)
|
|
197
|
+
if len(message) > max_size:
|
|
198
|
+
return format_error(f"Message too large (max {max_size} chars)")
|
|
199
|
+
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def require_args(min_count, usage_msg, extra_msg=""):
|
|
203
|
+
"""Check argument count and exit with usage if insufficient"""
|
|
204
|
+
if len(sys.argv) < min_count:
|
|
205
|
+
print(f"Usage: {usage_msg}")
|
|
206
|
+
if extra_msg:
|
|
207
|
+
print(extra_msg)
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
|
|
210
|
+
def load_positions(pos_file):
|
|
211
|
+
"""Load positions from file with error handling"""
|
|
212
|
+
positions = {}
|
|
213
|
+
if pos_file.exists():
|
|
214
|
+
try:
|
|
215
|
+
with open(pos_file, 'r') as f:
|
|
216
|
+
positions = json.load(f)
|
|
217
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
218
|
+
pass
|
|
219
|
+
return positions
|
|
220
|
+
|
|
221
|
+
def send_message(from_instance, message):
|
|
222
|
+
"""Send a message to the log"""
|
|
223
|
+
try:
|
|
224
|
+
ensure_hcom_dir()
|
|
225
|
+
log_file = get_hcom_dir() / "hcom.log"
|
|
226
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
227
|
+
|
|
228
|
+
escaped_message = message.replace('|', '\\|')
|
|
229
|
+
escaped_from = from_instance.replace('|', '\\|')
|
|
230
|
+
|
|
231
|
+
timestamp = datetime.now().isoformat()
|
|
232
|
+
line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
|
|
233
|
+
|
|
234
|
+
with open(log_file, 'a') as f:
|
|
235
|
+
f.write(line)
|
|
236
|
+
f.flush()
|
|
237
|
+
|
|
238
|
+
return True
|
|
239
|
+
except Exception:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def should_deliver_message(msg, instance_name):
|
|
243
|
+
"""Check if message should be delivered based on @-mentions"""
|
|
244
|
+
text = msg['message']
|
|
245
|
+
|
|
246
|
+
if '@' not in text:
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
mentions = MENTION_PATTERN.findall(text)
|
|
250
|
+
|
|
251
|
+
if not mentions:
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
for mention in mentions:
|
|
255
|
+
if instance_name.lower().startswith(mention.lower()):
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
# ==================== Parsing and Helper Functions ====================
|
|
261
|
+
|
|
262
|
+
def parse_open_args(args):
|
|
263
|
+
"""Parse arguments for open command
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
tuple: (instances, prefix, claude_args)
|
|
267
|
+
instances: list of agent names or 'generic'
|
|
268
|
+
prefix: team name prefix or None
|
|
269
|
+
claude_args: additional args to pass to claude
|
|
270
|
+
"""
|
|
271
|
+
instances = []
|
|
272
|
+
prefix = None
|
|
273
|
+
claude_args = []
|
|
274
|
+
|
|
275
|
+
i = 0
|
|
276
|
+
while i < len(args):
|
|
277
|
+
arg = args[i]
|
|
278
|
+
|
|
279
|
+
if arg == '--prefix':
|
|
280
|
+
if i + 1 >= len(args):
|
|
281
|
+
raise ValueError(format_error('--prefix requires an argument'))
|
|
282
|
+
prefix = args[i + 1]
|
|
283
|
+
if '|' in prefix:
|
|
284
|
+
raise ValueError(format_error('Team name cannot contain pipe characters'))
|
|
285
|
+
i += 2
|
|
286
|
+
elif arg == '--claude-args':
|
|
287
|
+
# Next argument contains claude args as a string
|
|
288
|
+
if i + 1 >= len(args):
|
|
289
|
+
raise ValueError(format_error('--claude-args requires an argument'))
|
|
290
|
+
claude_args = shlex.split(args[i + 1])
|
|
291
|
+
i += 2
|
|
292
|
+
else:
|
|
293
|
+
try:
|
|
294
|
+
count = int(arg)
|
|
295
|
+
if count < 0:
|
|
296
|
+
raise ValueError(format_error(f"Cannot launch negative instances: {count}"))
|
|
297
|
+
if count > 100:
|
|
298
|
+
raise ValueError(format_error(f"Too many instances requested: {count}", "Maximum 100 instances at once"))
|
|
299
|
+
instances.extend(['generic'] * count)
|
|
300
|
+
except ValueError as e:
|
|
301
|
+
if "Cannot launch" in str(e) or "Too many instances" in str(e):
|
|
302
|
+
raise
|
|
303
|
+
# Not a number, treat as agent name
|
|
304
|
+
instances.append(arg)
|
|
305
|
+
i += 1
|
|
306
|
+
|
|
307
|
+
if not instances:
|
|
308
|
+
instances = ['generic']
|
|
309
|
+
|
|
310
|
+
return instances, prefix, claude_args
|
|
311
|
+
|
|
312
|
+
def resolve_agent(name):
|
|
313
|
+
"""Resolve agent file by name
|
|
314
|
+
|
|
315
|
+
Looks for agent files in:
|
|
316
|
+
1. .claude/agents/{name}.md (local)
|
|
317
|
+
2. ~/.claude/agents/{name}.md (global)
|
|
318
|
+
|
|
319
|
+
Returns the content after stripping YAML frontmatter
|
|
320
|
+
"""
|
|
321
|
+
for base_path in [Path('.'), Path.home()]:
|
|
322
|
+
agent_path = base_path / '.claude/agents' / f'{name}.md'
|
|
323
|
+
if agent_path.exists():
|
|
324
|
+
content = agent_path.read_text()
|
|
325
|
+
stripped = strip_frontmatter(content)
|
|
326
|
+
if not stripped.strip():
|
|
327
|
+
raise ValueError(format_error(f"Agent '{name}' has empty content", 'Check the agent file contains a system prompt'))
|
|
328
|
+
return stripped
|
|
329
|
+
|
|
330
|
+
raise FileNotFoundError(format_error(f'Agent not found: {name}', 'Check available agents or create the agent file'))
|
|
331
|
+
|
|
332
|
+
def strip_frontmatter(content):
|
|
333
|
+
"""Strip YAML frontmatter from agent file"""
|
|
334
|
+
if content.startswith('---'):
|
|
335
|
+
# Find the closing --- on its own line
|
|
336
|
+
lines = content.split('\n')
|
|
337
|
+
for i, line in enumerate(lines[1:], 1):
|
|
338
|
+
if line.strip() == '---':
|
|
339
|
+
return '\n'.join(lines[i+1:]).strip()
|
|
340
|
+
return content
|
|
341
|
+
|
|
342
|
+
def get_display_name(transcript_path, prefix=None):
|
|
343
|
+
"""Get display name for instance"""
|
|
344
|
+
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']
|
|
345
|
+
dir_name = Path.cwd().name
|
|
346
|
+
dir_chars = (dir_name + 'xx')[:2].lower() # Pad short names to ensure 2 chars
|
|
347
|
+
|
|
348
|
+
conversation_uuid = get_conversation_uuid(transcript_path)
|
|
349
|
+
|
|
350
|
+
if conversation_uuid:
|
|
351
|
+
hash_val = sum(ord(c) for c in conversation_uuid)
|
|
352
|
+
uuid_char = conversation_uuid[0]
|
|
353
|
+
base_name = f"{dir_chars}{syls[hash_val % len(syls)]}{uuid_char}"
|
|
354
|
+
else:
|
|
355
|
+
base_name = f"{dir_chars}claude"
|
|
356
|
+
|
|
357
|
+
if prefix:
|
|
358
|
+
return f"{prefix}-{base_name}"
|
|
359
|
+
return base_name
|
|
360
|
+
|
|
361
|
+
def _remove_hcom_hooks_from_settings(settings):
|
|
362
|
+
"""Remove hcom hooks from settings dict"""
|
|
363
|
+
if 'hooks' not in settings:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
for event in ['PostToolUse', 'Stop', 'Notification']:
|
|
367
|
+
if event not in settings['hooks']:
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
settings['hooks'][event] = [
|
|
371
|
+
matcher for matcher in settings['hooks'][event]
|
|
372
|
+
if not any(
|
|
373
|
+
any(pattern in hook.get('command', '')
|
|
374
|
+
for pattern in ['hcom post', 'hcom stop', 'hcom notify',
|
|
375
|
+
'hcom.py post', 'hcom.py stop', 'hcom.py notify',
|
|
376
|
+
'hcom.py" post', 'hcom.py" stop', 'hcom.py" notify'])
|
|
377
|
+
for hook in matcher.get('hooks', []))
|
|
378
|
+
]
|
|
379
|
+
|
|
380
|
+
if not settings['hooks'][event]:
|
|
381
|
+
del settings['hooks'][event]
|
|
382
|
+
|
|
383
|
+
if not settings['hooks']:
|
|
384
|
+
del settings['hooks']
|
|
385
|
+
|
|
386
|
+
def build_env_string(env_vars, format_type="bash"):
|
|
387
|
+
"""Build environment variable string for different shells"""
|
|
388
|
+
if format_type == "bash_export":
|
|
389
|
+
# Properly escape values for bash
|
|
390
|
+
return ' '.join(f'export {k}={shlex.quote(str(v))};' for k, v in env_vars.items())
|
|
391
|
+
elif format_type == "powershell":
|
|
392
|
+
# PowerShell environment variable syntax
|
|
393
|
+
return ' ; '.join(f'$env:{k}="{str(v).replace('"', '`"')}"' for k, v in env_vars.items())
|
|
394
|
+
else:
|
|
395
|
+
return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
|
|
396
|
+
|
|
397
|
+
def format_error(message, suggestion=None):
|
|
398
|
+
"""Format error message consistently"""
|
|
399
|
+
base = f"Error: {message}"
|
|
400
|
+
if suggestion:
|
|
401
|
+
base += f". {suggestion}"
|
|
402
|
+
return base
|
|
403
|
+
|
|
404
|
+
def format_warning(message):
|
|
405
|
+
"""Format warning message consistently"""
|
|
406
|
+
return f"Warning: {message}"
|
|
407
|
+
|
|
408
|
+
def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat"):
|
|
409
|
+
"""Build Claude command with proper argument handling
|
|
410
|
+
|
|
411
|
+
Returns tuple: (command_string, temp_file_path_or_none)
|
|
412
|
+
For agent content, writes to temp file and uses cat to read it.
|
|
413
|
+
"""
|
|
414
|
+
cmd_parts = ['claude']
|
|
415
|
+
temp_file_path = None
|
|
416
|
+
|
|
417
|
+
if claude_args:
|
|
418
|
+
for arg in claude_args:
|
|
419
|
+
cmd_parts.append(shlex.quote(arg))
|
|
420
|
+
|
|
421
|
+
if agent_content:
|
|
422
|
+
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False,
|
|
423
|
+
prefix='hcom_agent_', dir=tempfile.gettempdir())
|
|
424
|
+
temp_file.write(agent_content)
|
|
425
|
+
temp_file.close()
|
|
426
|
+
temp_file_path = temp_file.name
|
|
427
|
+
|
|
428
|
+
if claude_args and any(arg in claude_args for arg in ['-p', '--print']):
|
|
429
|
+
flag = '--system-prompt'
|
|
430
|
+
else:
|
|
431
|
+
flag = '--append-system-prompt'
|
|
432
|
+
|
|
433
|
+
cmd_parts.append(flag)
|
|
434
|
+
if sys.platform == 'win32':
|
|
435
|
+
# PowerShell handles paths differently, quote with single quotes
|
|
436
|
+
escaped_path = temp_file_path.replace("'", "''")
|
|
437
|
+
cmd_parts.append(f"\"$(Get-Content '{escaped_path}' -Raw)\"")
|
|
438
|
+
else:
|
|
439
|
+
cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
|
|
440
|
+
|
|
441
|
+
if claude_args or agent_content:
|
|
442
|
+
cmd_parts.append('--')
|
|
443
|
+
|
|
444
|
+
# Quote initial prompt normally
|
|
445
|
+
cmd_parts.append(shlex.quote(initial_prompt))
|
|
446
|
+
|
|
447
|
+
return ' '.join(cmd_parts), temp_file_path
|
|
448
|
+
|
|
449
|
+
def escape_for_platform(text, platform_type):
|
|
450
|
+
"""Centralized escaping for different platforms"""
|
|
451
|
+
if platform_type == 'applescript':
|
|
452
|
+
# AppleScript escaping for text within double quotes
|
|
453
|
+
# We need to escape backslashes first, then other special chars
|
|
454
|
+
return (text.replace('\\', '\\\\')
|
|
455
|
+
.replace('"', '\\"') # Escape double quotes
|
|
456
|
+
.replace('\n', '\\n') # Escape newlines
|
|
457
|
+
.replace('\r', '\\r') # Escape carriage returns
|
|
458
|
+
.replace('\t', '\\t')) # Escape tabs
|
|
459
|
+
elif platform_type == 'powershell':
|
|
460
|
+
# PowerShell escaping - use backticks for special chars
|
|
461
|
+
return text.replace('`', '``').replace('"', '`"').replace('$', '`$')
|
|
462
|
+
else: # POSIX/bash
|
|
463
|
+
return shlex.quote(text)
|
|
464
|
+
|
|
465
|
+
def safe_command_substitution(template, **substitutions):
|
|
466
|
+
"""Safely substitute values into command templates with automatic quoting"""
|
|
467
|
+
result = template
|
|
468
|
+
for key, value in substitutions.items():
|
|
469
|
+
placeholder = f'{{{key}}}'
|
|
470
|
+
if placeholder in result:
|
|
471
|
+
# Auto-quote substitutions unless already quoted
|
|
472
|
+
if key == 'env':
|
|
473
|
+
# env_str is already properly quoted
|
|
474
|
+
quoted_value = str(value)
|
|
475
|
+
else:
|
|
476
|
+
quoted_value = shlex.quote(str(value))
|
|
477
|
+
result = result.replace(placeholder, quoted_value)
|
|
478
|
+
return result
|
|
479
|
+
|
|
480
|
+
def launch_terminal(command, env, config=None, cwd=None):
|
|
481
|
+
"""Launch terminal with command
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
command: Either a string command or list of command parts
|
|
485
|
+
env: Environment variables to set
|
|
486
|
+
config: Configuration dict
|
|
487
|
+
cwd: Working directory
|
|
488
|
+
"""
|
|
489
|
+
import subprocess
|
|
490
|
+
|
|
491
|
+
if config is None:
|
|
492
|
+
config = get_cached_config()
|
|
493
|
+
|
|
494
|
+
env_vars = os.environ.copy()
|
|
495
|
+
env_vars.update(env)
|
|
496
|
+
|
|
497
|
+
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
498
|
+
|
|
499
|
+
# Command should now always be a string from build_claude_command
|
|
500
|
+
command_str = command
|
|
501
|
+
|
|
502
|
+
if terminal_mode == 'show_commands':
|
|
503
|
+
env_str = build_env_string(env)
|
|
504
|
+
print(f"{env_str} {command_str}")
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
elif terminal_mode == 'same_terminal':
|
|
508
|
+
print(f"Launching Claude in current terminal...")
|
|
509
|
+
result = subprocess.run(command_str, shell=True, env=env_vars, cwd=cwd)
|
|
510
|
+
return result.returncode == 0
|
|
511
|
+
|
|
512
|
+
system = platform.system()
|
|
513
|
+
|
|
514
|
+
custom_cmd = get_config_value('terminal_command')
|
|
515
|
+
if custom_cmd and custom_cmd != 'None' and custom_cmd != 'null':
|
|
516
|
+
# Replace placeholders
|
|
517
|
+
env_str = build_env_string(env)
|
|
518
|
+
working_dir = cwd or os.getcwd()
|
|
519
|
+
|
|
520
|
+
final_cmd = safe_command_substitution(
|
|
521
|
+
custom_cmd,
|
|
522
|
+
cmd=command_str,
|
|
523
|
+
env=env_str, # Already quoted
|
|
524
|
+
cwd=working_dir
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
result = subprocess.run(final_cmd, shell=True, capture_output=True)
|
|
528
|
+
if result.returncode != 0:
|
|
529
|
+
raise subprocess.CalledProcessError(result.returncode, final_cmd, result.stderr)
|
|
530
|
+
return True
|
|
531
|
+
|
|
532
|
+
if system == 'Darwin': # macOS
|
|
533
|
+
env_setup = build_env_string(env, "bash_export")
|
|
534
|
+
# Include cd command if cwd is specified
|
|
535
|
+
if cwd:
|
|
536
|
+
full_cmd = f'cd {shlex.quote(cwd)}; {env_setup} {command_str}'
|
|
537
|
+
else:
|
|
538
|
+
full_cmd = f'{env_setup} {command_str}'
|
|
539
|
+
|
|
540
|
+
# Escape the command for AppleScript double-quoted string
|
|
541
|
+
escaped = escape_for_platform(full_cmd, 'applescript')
|
|
542
|
+
|
|
543
|
+
script = f'tell app "Terminal" to do script "{escaped}"'
|
|
544
|
+
subprocess.run(['osascript', '-e', script])
|
|
545
|
+
return True
|
|
546
|
+
|
|
547
|
+
elif system == 'Linux':
|
|
548
|
+
terminals = [
|
|
549
|
+
('gnome-terminal', ['gnome-terminal', '--', 'bash', '-c']),
|
|
550
|
+
('konsole', ['konsole', '-e', 'bash', '-c']),
|
|
551
|
+
('xterm', ['xterm', '-e', 'bash', '-c'])
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
for term_name, term_cmd in terminals:
|
|
555
|
+
if shutil.which(term_name):
|
|
556
|
+
env_cmd = build_env_string(env)
|
|
557
|
+
# Include cd command if cwd is specified
|
|
558
|
+
if cwd:
|
|
559
|
+
full_cmd = f'cd "{cwd}"; {env_cmd} {command_str}; exec bash'
|
|
560
|
+
else:
|
|
561
|
+
full_cmd = f'{env_cmd} {command_str}; exec bash'
|
|
562
|
+
subprocess.run(term_cmd + [full_cmd])
|
|
563
|
+
return True
|
|
564
|
+
|
|
565
|
+
raise Exception(format_error("No supported terminal emulator found", "Install gnome-terminal, konsole, xfce4-terminal, or xterm"))
|
|
566
|
+
|
|
567
|
+
elif system == 'Windows':
|
|
568
|
+
# Windows Terminal with PowerShell
|
|
569
|
+
env_setup = build_env_string(env, "powershell")
|
|
570
|
+
# Include cd command if cwd is specified
|
|
571
|
+
if cwd:
|
|
572
|
+
full_cmd = f'cd "{cwd}" ; {env_setup} ; {command_str}'
|
|
573
|
+
else:
|
|
574
|
+
full_cmd = f'{env_setup} ; {command_str}'
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
# Try Windows Terminal with PowerShell
|
|
578
|
+
subprocess.run(['wt', 'powershell', '-NoExit', '-Command', full_cmd])
|
|
579
|
+
except FileNotFoundError:
|
|
580
|
+
# Fallback to PowerShell directly
|
|
581
|
+
subprocess.run(['powershell', '-NoExit', '-Command', full_cmd])
|
|
582
|
+
return True
|
|
583
|
+
|
|
584
|
+
else:
|
|
585
|
+
raise Exception(format_error(f"Unsupported platform: {system}", "Supported platforms: macOS, Linux, Windows"))
|
|
586
|
+
|
|
587
|
+
def setup_hooks():
|
|
588
|
+
"""Set up Claude hooks in current directory"""
|
|
589
|
+
claude_dir = Path.cwd() / '.claude'
|
|
590
|
+
claude_dir.mkdir(exist_ok=True)
|
|
591
|
+
|
|
592
|
+
settings_path = claude_dir / 'settings.local.json'
|
|
593
|
+
settings = {}
|
|
594
|
+
|
|
595
|
+
if settings_path.exists():
|
|
596
|
+
try:
|
|
597
|
+
with open(settings_path, 'r') as f:
|
|
598
|
+
settings = json.load(f)
|
|
599
|
+
except json.JSONDecodeError:
|
|
600
|
+
settings = {}
|
|
601
|
+
|
|
602
|
+
if 'hooks' not in settings:
|
|
603
|
+
settings['hooks'] = {}
|
|
604
|
+
if 'permissions' not in settings:
|
|
605
|
+
settings['permissions'] = {}
|
|
606
|
+
if 'allow' not in settings['permissions']:
|
|
607
|
+
settings['permissions']['allow'] = []
|
|
608
|
+
|
|
609
|
+
_remove_hcom_hooks_from_settings(settings)
|
|
610
|
+
|
|
611
|
+
if 'hooks' not in settings:
|
|
612
|
+
settings['hooks'] = {}
|
|
613
|
+
|
|
614
|
+
hcom_send_permission = 'Bash(echo "HCOM_SEND:*")'
|
|
615
|
+
if hcom_send_permission not in settings['permissions']['allow']:
|
|
616
|
+
settings['permissions']['allow'].append(hcom_send_permission)
|
|
617
|
+
|
|
618
|
+
# Detect hcom executable path
|
|
619
|
+
try:
|
|
620
|
+
import subprocess
|
|
621
|
+
subprocess.run(['hcom', '--help'], capture_output=True, check=True)
|
|
622
|
+
hcom_cmd = 'hcom'
|
|
623
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
624
|
+
# Use full path to current script
|
|
625
|
+
hcom_cmd = f'"{sys.executable}" "{os.path.abspath(__file__)}"'
|
|
626
|
+
|
|
627
|
+
# Add PostToolUse hook
|
|
628
|
+
if 'PostToolUse' not in settings['hooks']:
|
|
629
|
+
settings['hooks']['PostToolUse'] = []
|
|
630
|
+
|
|
631
|
+
settings['hooks']['PostToolUse'].append({
|
|
632
|
+
'matcher': '.*',
|
|
633
|
+
'hooks': [{
|
|
634
|
+
'type': 'command',
|
|
635
|
+
'command': f'{hcom_cmd} post'
|
|
636
|
+
}],
|
|
637
|
+
'timeout': 10 # 10 second timeout for PostToolUse
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
# Add Stop hook
|
|
641
|
+
if 'Stop' not in settings['hooks']:
|
|
642
|
+
settings['hooks']['Stop'] = []
|
|
643
|
+
|
|
644
|
+
wait_timeout = get_config_value('wait_timeout', 600)
|
|
645
|
+
|
|
646
|
+
settings['hooks']['Stop'].append({
|
|
647
|
+
'matcher': '',
|
|
648
|
+
'hooks': [{
|
|
649
|
+
'type': 'command',
|
|
650
|
+
'command': f'{hcom_cmd} stop',
|
|
651
|
+
'timeout': wait_timeout
|
|
652
|
+
}]
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
# Add Notification hook
|
|
656
|
+
if 'Notification' not in settings['hooks']:
|
|
657
|
+
settings['hooks']['Notification'] = []
|
|
658
|
+
|
|
659
|
+
settings['hooks']['Notification'].append({
|
|
660
|
+
'matcher': '',
|
|
661
|
+
'hooks': [{
|
|
662
|
+
'type': 'command',
|
|
663
|
+
'command': f'{hcom_cmd} notify'
|
|
664
|
+
}]
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
# Write settings atomically
|
|
668
|
+
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
669
|
+
|
|
670
|
+
return True
|
|
671
|
+
|
|
672
|
+
def is_interactive():
|
|
673
|
+
"""Check if running in interactive mode"""
|
|
674
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
675
|
+
|
|
676
|
+
def get_archive_timestamp():
|
|
677
|
+
"""Get timestamp for archive files"""
|
|
678
|
+
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
679
|
+
|
|
680
|
+
def get_conversation_uuid(transcript_path):
|
|
681
|
+
"""Get conversation UUID from transcript"""
|
|
682
|
+
try:
|
|
683
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
684
|
+
return None
|
|
685
|
+
|
|
686
|
+
with open(transcript_path, 'r') as f:
|
|
687
|
+
first_line = f.readline().strip()
|
|
688
|
+
if first_line:
|
|
689
|
+
entry = json.loads(first_line)
|
|
690
|
+
return entry.get('uuid')
|
|
691
|
+
except Exception:
|
|
692
|
+
pass
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
def is_parent_alive(parent_pid=None):
|
|
696
|
+
"""Check if parent process is alive"""
|
|
697
|
+
if parent_pid is None:
|
|
698
|
+
parent_pid = os.getppid()
|
|
699
|
+
|
|
700
|
+
if IS_WINDOWS:
|
|
701
|
+
try:
|
|
702
|
+
import ctypes
|
|
703
|
+
kernel32 = ctypes.windll.kernel32
|
|
704
|
+
handle = kernel32.OpenProcess(0x0400, False, parent_pid)
|
|
705
|
+
if handle == 0:
|
|
706
|
+
return False
|
|
707
|
+
kernel32.CloseHandle(handle)
|
|
708
|
+
return True
|
|
709
|
+
except Exception:
|
|
710
|
+
return True
|
|
711
|
+
else:
|
|
712
|
+
try:
|
|
713
|
+
os.kill(parent_pid, 0)
|
|
714
|
+
return True
|
|
715
|
+
except ProcessLookupError:
|
|
716
|
+
return False
|
|
717
|
+
except Exception:
|
|
718
|
+
return True
|
|
719
|
+
|
|
720
|
+
def parse_log_messages(log_file, start_pos=0):
|
|
721
|
+
"""Parse messages from log file"""
|
|
722
|
+
log_file = Path(log_file)
|
|
723
|
+
if not log_file.exists():
|
|
724
|
+
return []
|
|
725
|
+
|
|
726
|
+
messages = []
|
|
727
|
+
with open(log_file, 'r') as f:
|
|
728
|
+
f.seek(start_pos)
|
|
729
|
+
content = f.read()
|
|
730
|
+
|
|
731
|
+
if not content.strip():
|
|
732
|
+
return []
|
|
733
|
+
|
|
734
|
+
message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
|
|
735
|
+
|
|
736
|
+
for entry in message_entries:
|
|
737
|
+
if not entry or '|' not in entry:
|
|
738
|
+
continue
|
|
739
|
+
|
|
740
|
+
parts = entry.split('|', 2)
|
|
741
|
+
if len(parts) == 3:
|
|
742
|
+
timestamp, from_instance, message = parts
|
|
743
|
+
messages.append({
|
|
744
|
+
'timestamp': timestamp,
|
|
745
|
+
'from': from_instance.replace('\\|', '|'),
|
|
746
|
+
'message': message.replace('\\|', '|')
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
return messages
|
|
750
|
+
|
|
751
|
+
def get_new_messages(instance_name):
|
|
752
|
+
"""Get new messages for instance with @-mention filtering"""
|
|
753
|
+
ensure_hcom_dir()
|
|
754
|
+
log_file = get_hcom_dir() / "hcom.log"
|
|
755
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
756
|
+
|
|
757
|
+
if not log_file.exists():
|
|
758
|
+
return []
|
|
759
|
+
|
|
760
|
+
positions = load_positions(pos_file)
|
|
761
|
+
|
|
762
|
+
# Get last position for this instance
|
|
763
|
+
last_pos = 0
|
|
764
|
+
if instance_name in positions:
|
|
765
|
+
pos_data = positions.get(instance_name, {})
|
|
766
|
+
last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
|
|
767
|
+
|
|
768
|
+
all_messages = parse_log_messages(log_file, last_pos)
|
|
769
|
+
|
|
770
|
+
# Filter messages:
|
|
771
|
+
# 1. Exclude own messages
|
|
772
|
+
# 2. Apply @-mention filtering
|
|
773
|
+
messages = []
|
|
774
|
+
for msg in all_messages:
|
|
775
|
+
if msg['from'] != instance_name:
|
|
776
|
+
if should_deliver_message(msg, instance_name):
|
|
777
|
+
messages.append(msg)
|
|
778
|
+
|
|
779
|
+
# Update position to end of file
|
|
780
|
+
with open(log_file, 'r') as f:
|
|
781
|
+
f.seek(0, 2) # Seek to end
|
|
782
|
+
new_pos = f.tell()
|
|
783
|
+
|
|
784
|
+
# Update position file
|
|
785
|
+
if instance_name not in positions:
|
|
786
|
+
positions[instance_name] = {}
|
|
787
|
+
|
|
788
|
+
positions[instance_name]['pos'] = new_pos
|
|
789
|
+
|
|
790
|
+
atomic_write(pos_file, json.dumps(positions, indent=2))
|
|
791
|
+
|
|
792
|
+
return messages
|
|
793
|
+
|
|
794
|
+
def format_age(seconds):
|
|
795
|
+
"""Format time ago in human readable form"""
|
|
796
|
+
if seconds < 60:
|
|
797
|
+
return f"{int(seconds)}s"
|
|
798
|
+
elif seconds < 3600:
|
|
799
|
+
return f"{int(seconds/60)}m"
|
|
800
|
+
else:
|
|
801
|
+
return f"{int(seconds/3600)}h"
|
|
802
|
+
|
|
803
|
+
def get_transcript_status(transcript_path):
|
|
804
|
+
"""Parse transcript to determine current Claude state"""
|
|
805
|
+
try:
|
|
806
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
807
|
+
return "inactive", "", "", 0
|
|
808
|
+
|
|
809
|
+
with open(transcript_path, 'r') as f:
|
|
810
|
+
lines = f.readlines()[-5:]
|
|
811
|
+
|
|
812
|
+
for line in reversed(lines):
|
|
813
|
+
entry = json.loads(line)
|
|
814
|
+
timestamp = datetime.fromisoformat(entry['timestamp']).timestamp()
|
|
815
|
+
age = int(time.time() - timestamp)
|
|
816
|
+
|
|
817
|
+
if entry['type'] == 'system':
|
|
818
|
+
content = entry.get('content', '')
|
|
819
|
+
if 'Running' in content:
|
|
820
|
+
tool_name = content.split('Running ')[1].split('[')[0].strip()
|
|
821
|
+
return "executing", f"({format_age(age)})", tool_name, timestamp
|
|
822
|
+
|
|
823
|
+
elif entry['type'] == 'assistant':
|
|
824
|
+
content = entry.get('content', [])
|
|
825
|
+
if any('tool_use' in str(item) for item in content):
|
|
826
|
+
return "executing", f"({format_age(age)})", "tool", timestamp
|
|
827
|
+
else:
|
|
828
|
+
return "responding", f"({format_age(age)})", "", timestamp
|
|
829
|
+
|
|
830
|
+
elif entry['type'] == 'user':
|
|
831
|
+
return "thinking", f"({format_age(age)})", "", timestamp
|
|
832
|
+
|
|
833
|
+
return "inactive", "", "", 0
|
|
834
|
+
except Exception:
|
|
835
|
+
return "inactive", "", "", 0
|
|
836
|
+
|
|
837
|
+
def get_instance_status(pos_data):
|
|
838
|
+
"""Get current status of instance"""
|
|
839
|
+
now = int(time.time())
|
|
840
|
+
wait_timeout = get_config_value('wait_timeout', 600)
|
|
841
|
+
|
|
842
|
+
last_permission = pos_data.get("last_permission_request", 0)
|
|
843
|
+
last_stop = pos_data.get("last_stop", 0)
|
|
844
|
+
last_tool = pos_data.get("last_tool", 0)
|
|
845
|
+
|
|
846
|
+
transcript_timestamp = 0
|
|
847
|
+
transcript_status = "inactive"
|
|
848
|
+
|
|
849
|
+
transcript_path = pos_data.get("transcript_path", "")
|
|
850
|
+
if transcript_path:
|
|
851
|
+
status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
|
|
852
|
+
transcript_status = status
|
|
853
|
+
|
|
854
|
+
events = [
|
|
855
|
+
(last_permission, "blocked"),
|
|
856
|
+
(last_stop, "waiting"),
|
|
857
|
+
(last_tool, "inactive"),
|
|
858
|
+
(transcript_timestamp, transcript_status)
|
|
859
|
+
]
|
|
860
|
+
|
|
861
|
+
recent_events = [(ts, status) for ts, status in events if ts > 0]
|
|
862
|
+
if not recent_events:
|
|
863
|
+
return "inactive", ""
|
|
864
|
+
|
|
865
|
+
most_recent_time, most_recent_status = max(recent_events)
|
|
866
|
+
age = now - most_recent_time
|
|
867
|
+
|
|
868
|
+
if age > wait_timeout:
|
|
869
|
+
return "inactive", ""
|
|
870
|
+
|
|
871
|
+
return most_recent_status, f"({format_age(age)})"
|
|
872
|
+
|
|
873
|
+
def get_status_block(status_type):
|
|
874
|
+
"""Get colored status block for a status type"""
|
|
875
|
+
color, symbol = STATUS_MAP.get(status_type, (BG_RED, "?"))
|
|
876
|
+
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
877
|
+
return f"{text_color}{BOLD}{color} {symbol} {RESET}"
|
|
878
|
+
|
|
879
|
+
def format_message_line(msg, truncate=False):
|
|
880
|
+
"""Format a message for display"""
|
|
881
|
+
time_obj = datetime.fromisoformat(msg['timestamp'])
|
|
882
|
+
time_str = time_obj.strftime("%H:%M")
|
|
883
|
+
|
|
884
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
885
|
+
sender_emoji = get_config_value('sender_emoji', '🐳')
|
|
886
|
+
|
|
887
|
+
display_name = f"{sender_emoji} {msg['from']}" if msg['from'] == sender_name else msg['from']
|
|
888
|
+
|
|
889
|
+
if truncate:
|
|
890
|
+
sender = display_name[:10]
|
|
891
|
+
message = msg['message'][:50]
|
|
892
|
+
return f" {DIM}{time_str}{RESET} {BOLD}{sender}{RESET}: {message}"
|
|
893
|
+
else:
|
|
894
|
+
return f"{DIM}{time_str}{RESET} {BOLD}{display_name}{RESET}: {msg['message']}"
|
|
895
|
+
|
|
896
|
+
def show_recent_messages(messages, limit=None, truncate=False):
|
|
897
|
+
"""Show recent messages"""
|
|
898
|
+
if limit is None:
|
|
899
|
+
messages_to_show = messages
|
|
900
|
+
else:
|
|
901
|
+
start_idx = max(0, len(messages) - limit)
|
|
902
|
+
messages_to_show = messages[start_idx:]
|
|
903
|
+
|
|
904
|
+
for msg in messages_to_show:
|
|
905
|
+
print(format_message_line(msg, truncate))
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def get_terminal_height():
|
|
909
|
+
"""Get current terminal height"""
|
|
910
|
+
try:
|
|
911
|
+
return shutil.get_terminal_size().lines
|
|
912
|
+
except (AttributeError, OSError):
|
|
913
|
+
return 24
|
|
914
|
+
|
|
915
|
+
def show_recent_activity_alt_screen(limit=None):
|
|
916
|
+
"""Show recent messages in alt screen format with dynamic height"""
|
|
917
|
+
if limit is None:
|
|
918
|
+
# Calculate available height: total - header(8) - instances(varies) - footer(4) - input(3)
|
|
919
|
+
available_height = get_terminal_height() - 20
|
|
920
|
+
limit = max(2, available_height // 2)
|
|
921
|
+
|
|
922
|
+
log_file = get_hcom_dir() / 'hcom.log'
|
|
923
|
+
if log_file.exists():
|
|
924
|
+
messages = parse_log_messages(log_file)
|
|
925
|
+
show_recent_messages(messages, limit, truncate=True)
|
|
926
|
+
|
|
927
|
+
def show_instances_status():
|
|
928
|
+
"""Show status of all instances"""
|
|
929
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
930
|
+
if not pos_file.exists():
|
|
931
|
+
print(f" {DIM}No Claude instances connected{RESET}")
|
|
932
|
+
return
|
|
933
|
+
|
|
934
|
+
positions = load_positions(pos_file)
|
|
935
|
+
if not positions:
|
|
936
|
+
print(f" {DIM}No Claude instances connected{RESET}")
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
print("Instances in hcom:")
|
|
940
|
+
for instance_name, pos_data in positions.items():
|
|
941
|
+
status_type, age = get_instance_status(pos_data)
|
|
942
|
+
status_block = get_status_block(status_type)
|
|
943
|
+
directory = pos_data.get("directory", "unknown")
|
|
944
|
+
print(f" {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}{RESET} {directory}")
|
|
945
|
+
|
|
946
|
+
def show_instances_by_directory():
|
|
947
|
+
"""Show instances organized by their working directories"""
|
|
948
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
949
|
+
if not pos_file.exists():
|
|
950
|
+
print(f" {DIM}No Claude instances connected{RESET}")
|
|
951
|
+
return
|
|
952
|
+
|
|
953
|
+
positions = load_positions(pos_file)
|
|
954
|
+
if positions:
|
|
955
|
+
directories = {}
|
|
956
|
+
for instance_name, pos_data in positions.items():
|
|
957
|
+
directory = pos_data.get("directory", "unknown")
|
|
958
|
+
if directory not in directories:
|
|
959
|
+
directories[directory] = []
|
|
960
|
+
directories[directory].append((instance_name, pos_data))
|
|
961
|
+
|
|
962
|
+
for directory, instances in directories.items():
|
|
963
|
+
print(f" {directory}")
|
|
964
|
+
for instance_name, pos_data in instances:
|
|
965
|
+
status_type, age = get_instance_status(pos_data)
|
|
966
|
+
status_block = get_status_block(status_type)
|
|
967
|
+
last_tool = pos_data.get("last_tool", 0)
|
|
968
|
+
last_tool_name = pos_data.get("last_tool_name", "unknown")
|
|
969
|
+
last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
|
|
970
|
+
|
|
971
|
+
sid = pos_data.get("session_id", "")
|
|
972
|
+
session_info = f" | {sid}" if sid else ""
|
|
973
|
+
|
|
974
|
+
print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{session_info}{RESET}")
|
|
975
|
+
print()
|
|
976
|
+
else:
|
|
977
|
+
print(f" {DIM}Error reading instance data{RESET}")
|
|
978
|
+
|
|
979
|
+
def alt_screen_detailed_status_and_input():
|
|
980
|
+
"""Show detailed status in alt screen and get user input"""
|
|
981
|
+
sys.stdout.write("\033[?1049h\033[2J\033[H")
|
|
982
|
+
|
|
983
|
+
try:
|
|
984
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
985
|
+
print(f"{BOLD} HCOM DETAILED STATUS{RESET}")
|
|
986
|
+
print(f"{BOLD}{'=' * 70}{RESET}")
|
|
987
|
+
print(f"{FG_CYAN} HCOM: GLOBAL CHAT{RESET}")
|
|
988
|
+
print(f"{DIM} LOG FILE: {get_hcom_dir() / 'hcom.log'}{RESET}")
|
|
989
|
+
print(f"{DIM} UPDATED: {timestamp}{RESET}")
|
|
990
|
+
print(f"{BOLD}{'-' * 70}{RESET}")
|
|
991
|
+
print()
|
|
992
|
+
|
|
993
|
+
show_instances_by_directory()
|
|
994
|
+
|
|
995
|
+
print()
|
|
996
|
+
print(f"{BOLD} RECENT ACTIVITY:{RESET}")
|
|
997
|
+
|
|
998
|
+
show_recent_activity_alt_screen()
|
|
999
|
+
|
|
1000
|
+
print()
|
|
1001
|
+
print(f"{BOLD}{'-' * 70}{RESET}")
|
|
1002
|
+
print(f"{FG_GREEN} Type message and press Enter to send (empty to cancel):{RESET}")
|
|
1003
|
+
message = input(f"{FG_CYAN} > {RESET}")
|
|
1004
|
+
|
|
1005
|
+
print(f"{BOLD}{'=' * 70}{RESET}")
|
|
1006
|
+
|
|
1007
|
+
finally:
|
|
1008
|
+
sys.stdout.write("\033[?1049l")
|
|
1009
|
+
|
|
1010
|
+
return message
|
|
1011
|
+
|
|
1012
|
+
def get_status_summary():
|
|
1013
|
+
"""Get a one-line summary of all instance statuses"""
|
|
1014
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
1015
|
+
if not pos_file.exists():
|
|
1016
|
+
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1017
|
+
|
|
1018
|
+
positions = load_positions(pos_file)
|
|
1019
|
+
if not positions:
|
|
1020
|
+
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1021
|
+
|
|
1022
|
+
status_counts = {"thinking": 0, "responding": 0, "executing": 0, "waiting": 0, "blocked": 0, "inactive": 0}
|
|
1023
|
+
|
|
1024
|
+
for _, pos_data in positions.items():
|
|
1025
|
+
status_type, _ = get_instance_status(pos_data)
|
|
1026
|
+
if status_type in status_counts:
|
|
1027
|
+
status_counts[status_type] += 1
|
|
1028
|
+
|
|
1029
|
+
parts = []
|
|
1030
|
+
status_order = ["thinking", "responding", "executing", "waiting", "blocked", "inactive"]
|
|
1031
|
+
|
|
1032
|
+
for status_type in status_order:
|
|
1033
|
+
count = status_counts[status_type]
|
|
1034
|
+
if count > 0:
|
|
1035
|
+
color, symbol = STATUS_MAP[status_type]
|
|
1036
|
+
text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
|
|
1037
|
+
parts.append(f"{text_color}{BOLD}{color} {count} {symbol} {RESET}")
|
|
1038
|
+
|
|
1039
|
+
if parts:
|
|
1040
|
+
return "".join(parts)
|
|
1041
|
+
else:
|
|
1042
|
+
return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
|
|
1043
|
+
|
|
1044
|
+
def update_status(s):
|
|
1045
|
+
"""Update status line in place"""
|
|
1046
|
+
sys.stdout.write("\r\033[K" + s)
|
|
1047
|
+
sys.stdout.flush()
|
|
1048
|
+
|
|
1049
|
+
def log_line_with_status(message, status):
|
|
1050
|
+
"""Print message and immediately restore status"""
|
|
1051
|
+
sys.stdout.write("\r\033[K" + message + "\n")
|
|
1052
|
+
sys.stdout.write("\033[K" + status)
|
|
1053
|
+
sys.stdout.flush()
|
|
1054
|
+
|
|
1055
|
+
def initialize_instance_in_position_file(instance_name, conversation_uuid=None):
|
|
1056
|
+
"""Initialize an instance in the position file with all required fields"""
|
|
1057
|
+
ensure_hcom_dir()
|
|
1058
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
1059
|
+
positions = load_positions(pos_file)
|
|
1060
|
+
|
|
1061
|
+
if instance_name not in positions:
|
|
1062
|
+
positions[instance_name] = {
|
|
1063
|
+
"pos": 0,
|
|
1064
|
+
"directory": str(Path.cwd()),
|
|
1065
|
+
"conversation_uuid": conversation_uuid or "unknown",
|
|
1066
|
+
"last_tool": 0,
|
|
1067
|
+
"last_tool_name": "unknown",
|
|
1068
|
+
"last_stop": 0,
|
|
1069
|
+
"last_permission_request": 0,
|
|
1070
|
+
"transcript_path": "",
|
|
1071
|
+
"session_id": "",
|
|
1072
|
+
"help_shown": False,
|
|
1073
|
+
"notification_message": ""
|
|
1074
|
+
}
|
|
1075
|
+
atomic_write(pos_file, json.dumps(positions, indent=2))
|
|
1076
|
+
|
|
1077
|
+
def migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path):
|
|
1078
|
+
"""Migrate instance name from fallback to UUID-based if needed"""
|
|
1079
|
+
if instance_name.endswith("claude") and conversation_uuid:
|
|
1080
|
+
new_instance = get_display_name(transcript_path)
|
|
1081
|
+
if new_instance != instance_name and not new_instance.endswith("claude"):
|
|
1082
|
+
# Migrate from fallback name to UUID-based name
|
|
1083
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
1084
|
+
positions = load_positions(pos_file)
|
|
1085
|
+
if instance_name in positions:
|
|
1086
|
+
# Copy over the old instance data to new name
|
|
1087
|
+
positions[new_instance] = positions.pop(instance_name)
|
|
1088
|
+
# Update the conversation UUID in the migrated data
|
|
1089
|
+
positions[new_instance]["conversation_uuid"] = conversation_uuid
|
|
1090
|
+
atomic_write(pos_file, json.dumps(positions, indent=2))
|
|
1091
|
+
# Instance name migrated
|
|
1092
|
+
return new_instance
|
|
1093
|
+
return instance_name
|
|
1094
|
+
|
|
1095
|
+
def update_instance_position(instance_name, update_fields):
|
|
1096
|
+
"""Update instance position in position file"""
|
|
1097
|
+
ensure_hcom_dir()
|
|
1098
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
1099
|
+
|
|
1100
|
+
# Get file modification time before reading to detect races
|
|
1101
|
+
mtime_before = pos_file.stat().st_mtime_ns if pos_file.exists() else 0
|
|
1102
|
+
positions = load_positions(pos_file)
|
|
1103
|
+
|
|
1104
|
+
# Get or create instance data
|
|
1105
|
+
if instance_name not in positions:
|
|
1106
|
+
positions[instance_name] = {}
|
|
1107
|
+
|
|
1108
|
+
# Update only provided fields
|
|
1109
|
+
for key, value in update_fields.items():
|
|
1110
|
+
positions[instance_name][key] = value
|
|
1111
|
+
|
|
1112
|
+
# Check if file was modified while we were working
|
|
1113
|
+
mtime_after = pos_file.stat().st_mtime_ns if pos_file.exists() else 0
|
|
1114
|
+
if mtime_after != mtime_before:
|
|
1115
|
+
# Someone else modified it, retry once
|
|
1116
|
+
return update_instance_position(instance_name, update_fields)
|
|
1117
|
+
|
|
1118
|
+
# Write back atomically
|
|
1119
|
+
atomic_write(pos_file, json.dumps(positions, indent=2))
|
|
1120
|
+
|
|
1121
|
+
def check_and_show_first_use_help(instance_name):
|
|
1122
|
+
"""Check and show first-use help if needed"""
|
|
1123
|
+
|
|
1124
|
+
pos_file = get_hcom_dir() / "hcom.json"
|
|
1125
|
+
positions = load_positions(pos_file)
|
|
1126
|
+
|
|
1127
|
+
instance_data = positions.get(instance_name, {})
|
|
1128
|
+
if not instance_data.get('help_shown', False):
|
|
1129
|
+
# Mark help as shown
|
|
1130
|
+
update_instance_position(instance_name, {'help_shown': True})
|
|
1131
|
+
|
|
1132
|
+
# Get values using unified config system
|
|
1133
|
+
first_use_text = get_config_value('first_use_text', '')
|
|
1134
|
+
instance_hints = get_config_value('instance_hints', '')
|
|
1135
|
+
|
|
1136
|
+
help_text = f"Welcome! hcom chat active. Your alias: {instance_name}. " \
|
|
1137
|
+
f"Send messages: echo \"HCOM_SEND:your message\". " \
|
|
1138
|
+
f"{first_use_text} {instance_hints}".strip()
|
|
1139
|
+
|
|
1140
|
+
output = {"decision": HOOK_DECISION_BLOCK, "reason": help_text}
|
|
1141
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
1142
|
+
sys.exit(EXIT_BLOCK)
|
|
1143
|
+
|
|
1144
|
+
# ==================== Command Functions ====================
|
|
1145
|
+
|
|
1146
|
+
def show_main_screen_header():
|
|
1147
|
+
"""Show header for main screen"""
|
|
1148
|
+
sys.stdout.write("\033[2J\033[H")
|
|
1149
|
+
|
|
1150
|
+
log_file = get_hcom_dir() / 'hcom.log'
|
|
1151
|
+
all_messages = []
|
|
1152
|
+
if log_file.exists():
|
|
1153
|
+
all_messages = parse_log_messages(log_file)
|
|
1154
|
+
message_count = len(all_messages)
|
|
1155
|
+
|
|
1156
|
+
print(f"\n{BOLD}{'='*50}{RESET}")
|
|
1157
|
+
print(f" {FG_CYAN}HCOM: global chat{RESET}")
|
|
1158
|
+
|
|
1159
|
+
status_line = get_status_summary()
|
|
1160
|
+
print(f" {BOLD}INSTANCES:{RESET} {status_line}")
|
|
1161
|
+
print(f" {DIM}LOGS: {log_file} ({message_count} messages){RESET}")
|
|
1162
|
+
print(f"{BOLD}{'='*50}{RESET}\n")
|
|
1163
|
+
|
|
1164
|
+
return all_messages
|
|
1165
|
+
|
|
1166
|
+
def cmd_help():
|
|
1167
|
+
"""Show help text"""
|
|
1168
|
+
print("""hcom - Hook Communications
|
|
1169
|
+
|
|
1170
|
+
Usage:
|
|
1171
|
+
hcom open [n] Launch n Claude instances
|
|
1172
|
+
hcom open --prefix name n Launch with name prefix
|
|
1173
|
+
hcom watch View conversation dashboard
|
|
1174
|
+
hcom clear Clear and archive conversation
|
|
1175
|
+
hcom cleanup Remove hooks from current directory
|
|
1176
|
+
hcom cleanup --all Remove hooks from all tracked directories
|
|
1177
|
+
hcom help Show this help
|
|
1178
|
+
|
|
1179
|
+
Automation:
|
|
1180
|
+
hcom send 'msg' Send message
|
|
1181
|
+
hcom send '@prefix msg' Send to specific instances
|
|
1182
|
+
hcom watch --logs/--status Show logs or status""")
|
|
1183
|
+
return 0
|
|
1184
|
+
|
|
1185
|
+
def cmd_open(*args):
|
|
1186
|
+
"""Launch Claude instances with chat enabled"""
|
|
1187
|
+
try:
|
|
1188
|
+
# Parse arguments
|
|
1189
|
+
instances, prefix, claude_args = parse_open_args(list(args))
|
|
1190
|
+
|
|
1191
|
+
terminal_mode = get_config_value('terminal_mode', 'new_window')
|
|
1192
|
+
|
|
1193
|
+
# Fail fast for same_terminal with multiple instances
|
|
1194
|
+
if terminal_mode == 'same_terminal' and len(instances) > 1:
|
|
1195
|
+
print(format_error(
|
|
1196
|
+
f"same_terminal mode cannot launch {len(instances)} instances",
|
|
1197
|
+
"Use 'hcom open' for one generic instance or 'hcom open <agent>' for one agent"
|
|
1198
|
+
), file=sys.stderr)
|
|
1199
|
+
return 1
|
|
1200
|
+
|
|
1201
|
+
try:
|
|
1202
|
+
setup_hooks()
|
|
1203
|
+
except Exception as e:
|
|
1204
|
+
print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
|
|
1205
|
+
return 1
|
|
1206
|
+
|
|
1207
|
+
ensure_hcom_dir()
|
|
1208
|
+
log_file = get_hcom_dir() / 'hcom.log'
|
|
1209
|
+
pos_file = get_hcom_dir() / 'hcom.json'
|
|
1210
|
+
|
|
1211
|
+
if not log_file.exists():
|
|
1212
|
+
log_file.touch()
|
|
1213
|
+
if not pos_file.exists():
|
|
1214
|
+
atomic_write(pos_file, json.dumps({}, indent=2))
|
|
1215
|
+
|
|
1216
|
+
# Build environment variables for Claude instances
|
|
1217
|
+
base_env = build_claude_env()
|
|
1218
|
+
|
|
1219
|
+
# Add prefix-specific hints if provided
|
|
1220
|
+
if prefix:
|
|
1221
|
+
hint = f"To respond to {prefix} group: echo \"HCOM_SEND:@{prefix} message\""
|
|
1222
|
+
base_env['HCOM_INSTANCE_HINTS'] = hint
|
|
1223
|
+
|
|
1224
|
+
first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
|
|
1225
|
+
base_env['HCOM_FIRST_USE_TEXT'] = first_use
|
|
1226
|
+
|
|
1227
|
+
launched = 0
|
|
1228
|
+
initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
|
|
1229
|
+
|
|
1230
|
+
temp_files_to_cleanup = []
|
|
1231
|
+
|
|
1232
|
+
for instance_type in instances:
|
|
1233
|
+
# Build claude command
|
|
1234
|
+
if instance_type == 'generic':
|
|
1235
|
+
# Generic instance - no agent content
|
|
1236
|
+
claude_cmd, temp_file = build_claude_command(
|
|
1237
|
+
agent_content=None,
|
|
1238
|
+
claude_args=claude_args,
|
|
1239
|
+
initial_prompt=initial_prompt
|
|
1240
|
+
)
|
|
1241
|
+
else:
|
|
1242
|
+
# Agent instance
|
|
1243
|
+
try:
|
|
1244
|
+
agent_content = resolve_agent(instance_type)
|
|
1245
|
+
claude_cmd, temp_file = build_claude_command(
|
|
1246
|
+
agent_content=agent_content,
|
|
1247
|
+
claude_args=claude_args,
|
|
1248
|
+
initial_prompt=initial_prompt
|
|
1249
|
+
)
|
|
1250
|
+
if temp_file:
|
|
1251
|
+
temp_files_to_cleanup.append(temp_file)
|
|
1252
|
+
except (FileNotFoundError, ValueError) as e:
|
|
1253
|
+
print(str(e), file=sys.stderr)
|
|
1254
|
+
continue
|
|
1255
|
+
|
|
1256
|
+
try:
|
|
1257
|
+
launch_terminal(claude_cmd, base_env, cwd=os.getcwd())
|
|
1258
|
+
launched += 1
|
|
1259
|
+
except Exception as e:
|
|
1260
|
+
print(f"Error: Failed to launch terminal: {e}", file=sys.stderr)
|
|
1261
|
+
|
|
1262
|
+
# Clean up temp files after a delay (let terminals read them first)
|
|
1263
|
+
if temp_files_to_cleanup:
|
|
1264
|
+
def cleanup_temp_files():
|
|
1265
|
+
time.sleep(5) # Give terminals time to read the files
|
|
1266
|
+
for temp_file in temp_files_to_cleanup:
|
|
1267
|
+
try:
|
|
1268
|
+
os.unlink(temp_file)
|
|
1269
|
+
except:
|
|
1270
|
+
pass
|
|
1271
|
+
|
|
1272
|
+
cleanup_thread = threading.Thread(target=cleanup_temp_files)
|
|
1273
|
+
cleanup_thread.daemon = True
|
|
1274
|
+
cleanup_thread.start()
|
|
1275
|
+
|
|
1276
|
+
if launched == 0:
|
|
1277
|
+
print(format_error("No instances launched"), file=sys.stderr)
|
|
1278
|
+
return 1
|
|
1279
|
+
|
|
1280
|
+
# Success message
|
|
1281
|
+
print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
|
|
1282
|
+
|
|
1283
|
+
tips = [
|
|
1284
|
+
"Run 'hcom watch' to view/send in conversation dashboard",
|
|
1285
|
+
]
|
|
1286
|
+
if prefix:
|
|
1287
|
+
tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
|
|
1288
|
+
|
|
1289
|
+
print("\n" + "\n".join(f" • {tip}" for tip in tips))
|
|
1290
|
+
|
|
1291
|
+
return 0
|
|
1292
|
+
|
|
1293
|
+
except ValueError as e:
|
|
1294
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1295
|
+
return 1
|
|
1296
|
+
except Exception as e:
|
|
1297
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1298
|
+
return 1
|
|
1299
|
+
|
|
1300
|
+
def cmd_watch(*args):
|
|
1301
|
+
"""View conversation dashboard"""
|
|
1302
|
+
log_file = get_hcom_dir() / 'hcom.log'
|
|
1303
|
+
pos_file = get_hcom_dir() / 'hcom.json'
|
|
1304
|
+
|
|
1305
|
+
if not log_file.exists() and not pos_file.exists():
|
|
1306
|
+
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
1307
|
+
return 1
|
|
1308
|
+
|
|
1309
|
+
# Parse arguments
|
|
1310
|
+
show_logs = False
|
|
1311
|
+
show_status = False
|
|
1312
|
+
wait_timeout = None
|
|
1313
|
+
|
|
1314
|
+
for arg in args:
|
|
1315
|
+
if arg == '--logs':
|
|
1316
|
+
show_logs = True
|
|
1317
|
+
elif arg == '--status':
|
|
1318
|
+
show_status = True
|
|
1319
|
+
elif arg.startswith('--wait='):
|
|
1320
|
+
try:
|
|
1321
|
+
wait_timeout = int(arg.split('=')[1])
|
|
1322
|
+
except ValueError:
|
|
1323
|
+
print(format_error("Invalid timeout value"), file=sys.stderr)
|
|
1324
|
+
return 1
|
|
1325
|
+
elif arg == '--wait':
|
|
1326
|
+
# Default wait timeout if no value provided
|
|
1327
|
+
wait_timeout = 60
|
|
1328
|
+
|
|
1329
|
+
# Non-interactive mode (no TTY or flags specified)
|
|
1330
|
+
if not is_interactive() or show_logs or show_status:
|
|
1331
|
+
if show_logs:
|
|
1332
|
+
if log_file.exists():
|
|
1333
|
+
messages = parse_log_messages(log_file)
|
|
1334
|
+
for msg in messages:
|
|
1335
|
+
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
1336
|
+
else:
|
|
1337
|
+
print("No messages yet")
|
|
1338
|
+
|
|
1339
|
+
if wait_timeout is not None:
|
|
1340
|
+
start_time = time.time()
|
|
1341
|
+
last_pos = log_file.stat().st_size if log_file.exists() else 0
|
|
1342
|
+
|
|
1343
|
+
while time.time() - start_time < wait_timeout:
|
|
1344
|
+
if log_file.exists() and log_file.stat().st_size > last_pos:
|
|
1345
|
+
new_messages = parse_log_messages(log_file, last_pos)
|
|
1346
|
+
for msg in new_messages:
|
|
1347
|
+
print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
|
|
1348
|
+
last_pos = log_file.stat().st_size
|
|
1349
|
+
break
|
|
1350
|
+
time.sleep(1)
|
|
1351
|
+
|
|
1352
|
+
elif show_status:
|
|
1353
|
+
print("HCOM STATUS")
|
|
1354
|
+
print("INSTANCES:")
|
|
1355
|
+
show_instances_status()
|
|
1356
|
+
print("\nRECENT ACTIVITY:")
|
|
1357
|
+
show_recent_activity_alt_screen()
|
|
1358
|
+
print(f"\nLOG FILE: {log_file}")
|
|
1359
|
+
else:
|
|
1360
|
+
# No TTY - show automation usage
|
|
1361
|
+
print("Automation usage:")
|
|
1362
|
+
print(" hcom send 'message' Send message to group")
|
|
1363
|
+
print(" hcom watch --logs Show message history")
|
|
1364
|
+
print(" hcom watch --status Show instance status")
|
|
1365
|
+
|
|
1366
|
+
return 0
|
|
1367
|
+
|
|
1368
|
+
# Interactive dashboard mode
|
|
1369
|
+
last_pos = 0
|
|
1370
|
+
status_suffix = f"{DIM} [⏎] chat...{RESET}"
|
|
1371
|
+
|
|
1372
|
+
all_messages = show_main_screen_header()
|
|
1373
|
+
|
|
1374
|
+
show_recent_messages(all_messages, limit=5)
|
|
1375
|
+
print(f"\n{DIM}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
|
|
1376
|
+
|
|
1377
|
+
if log_file.exists():
|
|
1378
|
+
last_pos = log_file.stat().st_size
|
|
1379
|
+
|
|
1380
|
+
# Print newline to ensure status starts on its own line
|
|
1381
|
+
print()
|
|
1382
|
+
|
|
1383
|
+
current_status = get_status_summary()
|
|
1384
|
+
update_status(f"{current_status}{status_suffix}")
|
|
1385
|
+
last_status_update = time.time()
|
|
1386
|
+
|
|
1387
|
+
last_status = current_status
|
|
1388
|
+
|
|
1389
|
+
try:
|
|
1390
|
+
while True:
|
|
1391
|
+
now = time.time()
|
|
1392
|
+
if now - last_status_update > 2.0:
|
|
1393
|
+
current_status = get_status_summary()
|
|
1394
|
+
|
|
1395
|
+
# Only redraw if status text changed
|
|
1396
|
+
if current_status != last_status:
|
|
1397
|
+
update_status(f"{current_status}{status_suffix}")
|
|
1398
|
+
last_status = current_status
|
|
1399
|
+
|
|
1400
|
+
last_status_update = now
|
|
1401
|
+
|
|
1402
|
+
if log_file.exists() and log_file.stat().st_size > last_pos:
|
|
1403
|
+
new_messages = parse_log_messages(log_file, last_pos)
|
|
1404
|
+
# Use the last known status for consistency
|
|
1405
|
+
status_line_text = f"{last_status}{status_suffix}"
|
|
1406
|
+
for msg in new_messages:
|
|
1407
|
+
log_line_with_status(format_message_line(msg), status_line_text)
|
|
1408
|
+
last_pos = log_file.stat().st_size
|
|
1409
|
+
|
|
1410
|
+
# Check for keyboard input
|
|
1411
|
+
ready_for_input = False
|
|
1412
|
+
if IS_WINDOWS:
|
|
1413
|
+
import msvcrt
|
|
1414
|
+
if msvcrt.kbhit():
|
|
1415
|
+
msvcrt.getch()
|
|
1416
|
+
ready_for_input = True
|
|
1417
|
+
else:
|
|
1418
|
+
if select.select([sys.stdin], [], [], 0.1)[0]:
|
|
1419
|
+
sys.stdin.readline()
|
|
1420
|
+
ready_for_input = True
|
|
1421
|
+
|
|
1422
|
+
if ready_for_input:
|
|
1423
|
+
sys.stdout.write("\r\033[K")
|
|
1424
|
+
|
|
1425
|
+
message = alt_screen_detailed_status_and_input()
|
|
1426
|
+
|
|
1427
|
+
all_messages = show_main_screen_header()
|
|
1428
|
+
show_recent_messages(all_messages)
|
|
1429
|
+
print(f"\n{DIM}{'─'*10} [watching for new messages] {'─'*10}{RESET}")
|
|
1430
|
+
|
|
1431
|
+
if log_file.exists():
|
|
1432
|
+
last_pos = log_file.stat().st_size
|
|
1433
|
+
|
|
1434
|
+
if message and message.strip():
|
|
1435
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
1436
|
+
send_message(sender_name, message.strip())
|
|
1437
|
+
print(f"{FG_GREEN}✓ Sent{RESET}")
|
|
1438
|
+
|
|
1439
|
+
print()
|
|
1440
|
+
|
|
1441
|
+
current_status = get_status_summary()
|
|
1442
|
+
update_status(f"{current_status}{status_suffix}")
|
|
1443
|
+
|
|
1444
|
+
time.sleep(0.1)
|
|
1445
|
+
|
|
1446
|
+
except KeyboardInterrupt:
|
|
1447
|
+
sys.stdout.write("\033[?1049l\r\033[K")
|
|
1448
|
+
print(f"\n{DIM}[stopped]{RESET}")
|
|
1449
|
+
|
|
1450
|
+
return 0
|
|
1451
|
+
|
|
1452
|
+
def cmd_clear():
|
|
1453
|
+
"""Clear and archive conversation"""
|
|
1454
|
+
ensure_hcom_dir()
|
|
1455
|
+
log_file = get_hcom_dir() / 'hcom.log'
|
|
1456
|
+
pos_file = get_hcom_dir() / 'hcom.json'
|
|
1457
|
+
|
|
1458
|
+
# Check if hcom files exist
|
|
1459
|
+
if not log_file.exists() and not pos_file.exists():
|
|
1460
|
+
print("No hcom conversation to clear")
|
|
1461
|
+
return 0
|
|
1462
|
+
|
|
1463
|
+
# Generate archive timestamp
|
|
1464
|
+
timestamp = get_archive_timestamp()
|
|
1465
|
+
|
|
1466
|
+
# Archive existing files if they have content
|
|
1467
|
+
archived = False
|
|
1468
|
+
|
|
1469
|
+
try:
|
|
1470
|
+
# Archive log file if it exists and has content
|
|
1471
|
+
if log_file.exists() and log_file.stat().st_size > 0:
|
|
1472
|
+
archive_log = get_hcom_dir() / f'hcom-{timestamp}.log'
|
|
1473
|
+
log_file.rename(archive_log)
|
|
1474
|
+
archived = True
|
|
1475
|
+
elif log_file.exists():
|
|
1476
|
+
log_file.unlink()
|
|
1477
|
+
|
|
1478
|
+
# Archive position file if it exists and has content
|
|
1479
|
+
if pos_file.exists():
|
|
1480
|
+
try:
|
|
1481
|
+
with open(pos_file, 'r') as f:
|
|
1482
|
+
data = json.load(f)
|
|
1483
|
+
if data: # Non-empty position file
|
|
1484
|
+
archive_pos = get_hcom_dir() / f'hcom-{timestamp}.json'
|
|
1485
|
+
pos_file.rename(archive_pos)
|
|
1486
|
+
archived = True
|
|
1487
|
+
else:
|
|
1488
|
+
pos_file.unlink()
|
|
1489
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
1490
|
+
if pos_file.exists():
|
|
1491
|
+
pos_file.unlink()
|
|
1492
|
+
|
|
1493
|
+
log_file.touch()
|
|
1494
|
+
atomic_write(pos_file, json.dumps({}, indent=2))
|
|
1495
|
+
|
|
1496
|
+
if archived:
|
|
1497
|
+
print(f"Archived conversations to hcom-{timestamp}")
|
|
1498
|
+
print("Started fresh hcom conversation")
|
|
1499
|
+
return 0
|
|
1500
|
+
|
|
1501
|
+
except Exception as e:
|
|
1502
|
+
print(format_error(f"Failed to archive: {e}"), file=sys.stderr)
|
|
1503
|
+
return 1
|
|
1504
|
+
|
|
1505
|
+
def cleanup_directory_hooks(directory):
|
|
1506
|
+
"""Remove hcom hooks from a specific directory
|
|
1507
|
+
Returns tuple: (exit_code, message)
|
|
1508
|
+
exit_code: 0 for success, 1 for error
|
|
1509
|
+
message: what happened
|
|
1510
|
+
"""
|
|
1511
|
+
settings_path = Path(directory) / '.claude' / 'settings.local.json'
|
|
1512
|
+
|
|
1513
|
+
if not settings_path.exists():
|
|
1514
|
+
return 0, "No Claude settings found"
|
|
1515
|
+
|
|
1516
|
+
try:
|
|
1517
|
+
# Load existing settings
|
|
1518
|
+
with open(settings_path, 'r') as f:
|
|
1519
|
+
settings = json.load(f)
|
|
1520
|
+
|
|
1521
|
+
# Check if any hcom hooks exist
|
|
1522
|
+
hooks_found = False
|
|
1523
|
+
|
|
1524
|
+
original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
1525
|
+
for event in ['PostToolUse', 'Stop', 'Notification'])
|
|
1526
|
+
|
|
1527
|
+
_remove_hcom_hooks_from_settings(settings)
|
|
1528
|
+
|
|
1529
|
+
# Check if any were removed
|
|
1530
|
+
new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
|
|
1531
|
+
for event in ['PostToolUse', 'Stop', 'Notification'])
|
|
1532
|
+
if new_hook_count < original_hook_count:
|
|
1533
|
+
hooks_found = True
|
|
1534
|
+
|
|
1535
|
+
if 'permissions' in settings and 'allow' in settings['permissions']:
|
|
1536
|
+
original_perms = settings['permissions']['allow'][:]
|
|
1537
|
+
settings['permissions']['allow'] = [
|
|
1538
|
+
perm for perm in settings['permissions']['allow']
|
|
1539
|
+
if 'HCOM_SEND' not in perm
|
|
1540
|
+
]
|
|
1541
|
+
|
|
1542
|
+
if len(settings['permissions']['allow']) < len(original_perms):
|
|
1543
|
+
hooks_found = True
|
|
1544
|
+
|
|
1545
|
+
if not settings['permissions']['allow']:
|
|
1546
|
+
del settings['permissions']['allow']
|
|
1547
|
+
if not settings['permissions']:
|
|
1548
|
+
del settings['permissions']
|
|
1549
|
+
|
|
1550
|
+
if not hooks_found:
|
|
1551
|
+
return 0, "No hcom hooks found"
|
|
1552
|
+
|
|
1553
|
+
# Write back or delete settings
|
|
1554
|
+
if not settings or (len(settings) == 0):
|
|
1555
|
+
# Delete empty settings file
|
|
1556
|
+
settings_path.unlink()
|
|
1557
|
+
return 0, "Removed hcom hooks (settings file deleted)"
|
|
1558
|
+
else:
|
|
1559
|
+
# Write updated settings
|
|
1560
|
+
atomic_write(settings_path, json.dumps(settings, indent=2))
|
|
1561
|
+
return 0, "Removed hcom hooks from settings"
|
|
1562
|
+
|
|
1563
|
+
except json.JSONDecodeError:
|
|
1564
|
+
return 1, format_error("Corrupted settings.local.json file")
|
|
1565
|
+
except Exception as e:
|
|
1566
|
+
return 1, format_error(f"Cannot modify settings.local.json: {e}")
|
|
1567
|
+
|
|
1568
|
+
def cmd_cleanup(*args):
|
|
1569
|
+
"""Remove hcom hooks from current directory or all directories"""
|
|
1570
|
+
if args and args[0] == '--all':
|
|
1571
|
+
directories = set()
|
|
1572
|
+
|
|
1573
|
+
# Get all directories from current position file
|
|
1574
|
+
pos_file = get_hcom_dir() / 'hcom.json'
|
|
1575
|
+
if pos_file.exists():
|
|
1576
|
+
try:
|
|
1577
|
+
positions = load_positions(pos_file)
|
|
1578
|
+
for instance_data in positions.values():
|
|
1579
|
+
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
1580
|
+
directories.add(instance_data['directory'])
|
|
1581
|
+
except Exception as e:
|
|
1582
|
+
print(format_warning(f"Could not read current position file: {e}"))
|
|
1583
|
+
|
|
1584
|
+
hcom_dir = get_hcom_dir()
|
|
1585
|
+
try:
|
|
1586
|
+
# Look for archived position files (hcom-TIMESTAMP.json)
|
|
1587
|
+
for archive_file in hcom_dir.glob('hcom-*.json'):
|
|
1588
|
+
try:
|
|
1589
|
+
with open(archive_file, 'r') as f:
|
|
1590
|
+
archived_positions = json.load(f)
|
|
1591
|
+
for instance_data in archived_positions.values():
|
|
1592
|
+
if isinstance(instance_data, dict) and 'directory' in instance_data:
|
|
1593
|
+
directories.add(instance_data['directory'])
|
|
1594
|
+
except Exception as e:
|
|
1595
|
+
print(format_warning(f"Could not read archive {archive_file.name}: {e}"))
|
|
1596
|
+
except Exception as e:
|
|
1597
|
+
print(format_warning(f"Could not scan for archived files: {e}"))
|
|
1598
|
+
|
|
1599
|
+
if not directories:
|
|
1600
|
+
print("No directories found in hcom tracking (current or archived)")
|
|
1601
|
+
return 0
|
|
1602
|
+
|
|
1603
|
+
print(f"Found {len(directories)} unique directories to check")
|
|
1604
|
+
cleaned = 0
|
|
1605
|
+
failed = 0
|
|
1606
|
+
already_clean = 0
|
|
1607
|
+
|
|
1608
|
+
for directory in sorted(directories):
|
|
1609
|
+
# Check if directory exists
|
|
1610
|
+
if not Path(directory).exists():
|
|
1611
|
+
print(f"\nSkipping {directory} (directory no longer exists)")
|
|
1612
|
+
continue
|
|
1613
|
+
|
|
1614
|
+
print(f"\nChecking {directory}...")
|
|
1615
|
+
|
|
1616
|
+
# Check if settings file exists
|
|
1617
|
+
settings_path = Path(directory) / '.claude' / 'settings.local.json'
|
|
1618
|
+
if not settings_path.exists():
|
|
1619
|
+
print(" No Claude settings found")
|
|
1620
|
+
already_clean += 1
|
|
1621
|
+
continue
|
|
1622
|
+
|
|
1623
|
+
exit_code, message = cleanup_directory_hooks(Path(directory))
|
|
1624
|
+
if exit_code == 0:
|
|
1625
|
+
if "No hcom hooks found" in message:
|
|
1626
|
+
already_clean += 1
|
|
1627
|
+
print(f" {message}")
|
|
1628
|
+
else:
|
|
1629
|
+
cleaned += 1
|
|
1630
|
+
print(f" {message}")
|
|
1631
|
+
else:
|
|
1632
|
+
failed += 1
|
|
1633
|
+
print(f" {message}")
|
|
1634
|
+
|
|
1635
|
+
print(f"\nSummary:")
|
|
1636
|
+
print(f" Cleaned: {cleaned} directories")
|
|
1637
|
+
print(f" Already clean: {already_clean} directories")
|
|
1638
|
+
if failed > 0:
|
|
1639
|
+
print(f" Failed: {failed} directories")
|
|
1640
|
+
return 1
|
|
1641
|
+
return 0
|
|
1642
|
+
|
|
1643
|
+
else:
|
|
1644
|
+
exit_code, message = cleanup_directory_hooks(Path.cwd())
|
|
1645
|
+
print(message)
|
|
1646
|
+
return exit_code
|
|
1647
|
+
|
|
1648
|
+
def cmd_send(message):
|
|
1649
|
+
"""Send message to hcom"""
|
|
1650
|
+
# Check if hcom files exist
|
|
1651
|
+
log_file = get_hcom_dir() / 'hcom.log'
|
|
1652
|
+
pos_file = get_hcom_dir() / 'hcom.json'
|
|
1653
|
+
|
|
1654
|
+
if not log_file.exists() and not pos_file.exists():
|
|
1655
|
+
print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
|
|
1656
|
+
return 1
|
|
1657
|
+
|
|
1658
|
+
# Validate message
|
|
1659
|
+
error = validate_message(message)
|
|
1660
|
+
if error:
|
|
1661
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
1662
|
+
return 1
|
|
1663
|
+
|
|
1664
|
+
# Send message
|
|
1665
|
+
sender_name = get_config_value('sender_name', 'bigboss')
|
|
1666
|
+
|
|
1667
|
+
if send_message(sender_name, message):
|
|
1668
|
+
print("Message sent")
|
|
1669
|
+
return 0
|
|
1670
|
+
else:
|
|
1671
|
+
print(format_error("Failed to send message"), file=sys.stderr)
|
|
1672
|
+
return 1
|
|
1673
|
+
|
|
1674
|
+
# ==================== Hook Functions ====================
|
|
1675
|
+
|
|
1676
|
+
def handle_hook_post():
|
|
1677
|
+
"""Handle PostToolUse hook"""
|
|
1678
|
+
# Check if active
|
|
1679
|
+
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
1680
|
+
sys.exit(EXIT_SUCCESS)
|
|
1681
|
+
|
|
1682
|
+
try:
|
|
1683
|
+
# Read JSON input
|
|
1684
|
+
hook_data = json.load(sys.stdin)
|
|
1685
|
+
transcript_path = hook_data.get('transcript_path', '')
|
|
1686
|
+
instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
|
|
1687
|
+
conversation_uuid = get_conversation_uuid(transcript_path)
|
|
1688
|
+
|
|
1689
|
+
# Migrate instance name if needed (from fallback to UUID-based)
|
|
1690
|
+
instance_name = migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path)
|
|
1691
|
+
|
|
1692
|
+
# Initialize instance if needed
|
|
1693
|
+
if not instance_name.endswith("claude") or conversation_uuid:
|
|
1694
|
+
initialize_instance_in_position_file(instance_name, conversation_uuid)
|
|
1695
|
+
|
|
1696
|
+
# Update instance position
|
|
1697
|
+
update_instance_position(instance_name, {
|
|
1698
|
+
'last_tool': int(time.time()),
|
|
1699
|
+
'last_tool_name': hook_data.get('tool_name', 'unknown'),
|
|
1700
|
+
'session_id': hook_data.get('session_id', ''),
|
|
1701
|
+
'transcript_path': transcript_path,
|
|
1702
|
+
'conversation_uuid': conversation_uuid or 'unknown',
|
|
1703
|
+
'directory': str(Path.cwd())
|
|
1704
|
+
})
|
|
1705
|
+
|
|
1706
|
+
# Check for HCOM_SEND in Bash commands
|
|
1707
|
+
if hook_data.get('tool_name') == 'Bash':
|
|
1708
|
+
command = hook_data.get('tool_input', {}).get('command', '')
|
|
1709
|
+
if 'HCOM_SEND:' in command:
|
|
1710
|
+
# Extract message after HCOM_SEND:
|
|
1711
|
+
parts = command.split('HCOM_SEND:', 1)
|
|
1712
|
+
if len(parts) > 1:
|
|
1713
|
+
remainder = parts[1]
|
|
1714
|
+
|
|
1715
|
+
# The message might be in the format:
|
|
1716
|
+
# - message" (from echo "HCOM_SEND:message")
|
|
1717
|
+
# - message' (from echo 'HCOM_SEND:message')
|
|
1718
|
+
# - message (from echo HCOM_SEND:message)
|
|
1719
|
+
# - "message" (from echo HCOM_SEND:"message")
|
|
1720
|
+
|
|
1721
|
+
message = remainder.strip()
|
|
1722
|
+
|
|
1723
|
+
# If it starts and ends with matching quotes, remove them
|
|
1724
|
+
if len(message) >= 2 and \
|
|
1725
|
+
((message[0] == '"' and message[-1] == '"') or \
|
|
1726
|
+
(message[0] == "'" and message[-1] == "'")):
|
|
1727
|
+
message = message[1:-1]
|
|
1728
|
+
# If it ends with a quote but doesn't start with one,
|
|
1729
|
+
# it's likely from echo "HCOM_SEND:message" format
|
|
1730
|
+
elif message and message[-1] in '"\'':
|
|
1731
|
+
message = message[:-1]
|
|
1732
|
+
|
|
1733
|
+
if message and not instance_name.endswith("claude"):
|
|
1734
|
+
# Validate message
|
|
1735
|
+
error = validate_message(message)
|
|
1736
|
+
if error:
|
|
1737
|
+
output = {"reason": f"❌ {error}"}
|
|
1738
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
1739
|
+
sys.exit(EXIT_BLOCK)
|
|
1740
|
+
|
|
1741
|
+
send_message(instance_name, message)
|
|
1742
|
+
|
|
1743
|
+
# Check for pending messages to deliver
|
|
1744
|
+
if not instance_name.endswith("claude"):
|
|
1745
|
+
messages = get_new_messages(instance_name)
|
|
1746
|
+
|
|
1747
|
+
if messages:
|
|
1748
|
+
# Deliver messages via exit code 2
|
|
1749
|
+
max_messages = get_config_value('max_messages_per_delivery', 20)
|
|
1750
|
+
messages_to_show = messages[:max_messages]
|
|
1751
|
+
|
|
1752
|
+
output = {
|
|
1753
|
+
"decision": HOOK_DECISION_BLOCK,
|
|
1754
|
+
"reason": f"New messages from hcom:\n" +
|
|
1755
|
+
"\n".join([f"{m['from']}: {m['message']}" for m in messages_to_show])
|
|
1756
|
+
}
|
|
1757
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
1758
|
+
sys.exit(EXIT_BLOCK)
|
|
1759
|
+
|
|
1760
|
+
except Exception:
|
|
1761
|
+
pass
|
|
1762
|
+
|
|
1763
|
+
sys.exit(EXIT_SUCCESS)
|
|
1764
|
+
|
|
1765
|
+
def handle_hook_stop():
|
|
1766
|
+
"""Handle Stop hook"""
|
|
1767
|
+
# Check if active
|
|
1768
|
+
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
1769
|
+
sys.exit(EXIT_SUCCESS)
|
|
1770
|
+
|
|
1771
|
+
try:
|
|
1772
|
+
# Read hook input
|
|
1773
|
+
hook_data = json.load(sys.stdin)
|
|
1774
|
+
transcript_path = hook_data.get('transcript_path', '')
|
|
1775
|
+
instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
|
|
1776
|
+
conversation_uuid = get_conversation_uuid(transcript_path)
|
|
1777
|
+
|
|
1778
|
+
if instance_name.endswith("claude") and not conversation_uuid:
|
|
1779
|
+
sys.exit(EXIT_SUCCESS)
|
|
1780
|
+
|
|
1781
|
+
# Initialize instance if needed
|
|
1782
|
+
initialize_instance_in_position_file(instance_name, conversation_uuid)
|
|
1783
|
+
|
|
1784
|
+
# Update instance as waiting
|
|
1785
|
+
update_instance_position(instance_name, {
|
|
1786
|
+
'last_stop': int(time.time()),
|
|
1787
|
+
'session_id': hook_data.get('session_id', ''),
|
|
1788
|
+
'transcript_path': transcript_path,
|
|
1789
|
+
'conversation_uuid': conversation_uuid or 'unknown',
|
|
1790
|
+
'directory': str(Path.cwd())
|
|
1791
|
+
})
|
|
1792
|
+
|
|
1793
|
+
parent_pid = os.getppid()
|
|
1794
|
+
|
|
1795
|
+
# Check for first-use help
|
|
1796
|
+
check_and_show_first_use_help(instance_name)
|
|
1797
|
+
|
|
1798
|
+
# Simple polling loop with parent check
|
|
1799
|
+
timeout = get_config_value('wait_timeout', 600)
|
|
1800
|
+
start_time = time.time()
|
|
1801
|
+
|
|
1802
|
+
while time.time() - start_time < timeout:
|
|
1803
|
+
# Check if parent is alive
|
|
1804
|
+
if not is_parent_alive(parent_pid):
|
|
1805
|
+
sys.exit(EXIT_SUCCESS)
|
|
1806
|
+
|
|
1807
|
+
# Check for new messages
|
|
1808
|
+
messages = get_new_messages(instance_name)
|
|
1809
|
+
|
|
1810
|
+
if messages:
|
|
1811
|
+
# Deliver messages
|
|
1812
|
+
max_messages = get_config_value('max_messages_per_delivery', 20)
|
|
1813
|
+
messages_to_show = messages[:max_messages]
|
|
1814
|
+
|
|
1815
|
+
output = {
|
|
1816
|
+
"decision": HOOK_DECISION_BLOCK,
|
|
1817
|
+
"reason": f"New messages from hcom:\n" +
|
|
1818
|
+
"\n".join([f"{m['from']}: {m['message']}" for m in messages_to_show])
|
|
1819
|
+
}
|
|
1820
|
+
print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
|
|
1821
|
+
sys.exit(EXIT_BLOCK)
|
|
1822
|
+
|
|
1823
|
+
# Update heartbeat
|
|
1824
|
+
update_instance_position(instance_name, {
|
|
1825
|
+
'last_stop': int(time.time())
|
|
1826
|
+
})
|
|
1827
|
+
|
|
1828
|
+
time.sleep(1)
|
|
1829
|
+
|
|
1830
|
+
except Exception:
|
|
1831
|
+
pass
|
|
1832
|
+
|
|
1833
|
+
sys.exit(EXIT_SUCCESS)
|
|
1834
|
+
|
|
1835
|
+
def handle_hook_notification():
|
|
1836
|
+
"""Handle Notification hook"""
|
|
1837
|
+
# Check if active
|
|
1838
|
+
if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
|
|
1839
|
+
sys.exit(EXIT_SUCCESS)
|
|
1840
|
+
|
|
1841
|
+
try:
|
|
1842
|
+
# Read hook input
|
|
1843
|
+
hook_data = json.load(sys.stdin)
|
|
1844
|
+
transcript_path = hook_data.get('transcript_path', '')
|
|
1845
|
+
instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
|
|
1846
|
+
conversation_uuid = get_conversation_uuid(transcript_path)
|
|
1847
|
+
|
|
1848
|
+
if instance_name.endswith("claude") and not conversation_uuid:
|
|
1849
|
+
sys.exit(EXIT_SUCCESS)
|
|
1850
|
+
|
|
1851
|
+
# Initialize instance if needed
|
|
1852
|
+
initialize_instance_in_position_file(instance_name, conversation_uuid)
|
|
1853
|
+
|
|
1854
|
+
# Update permission request timestamp
|
|
1855
|
+
update_instance_position(instance_name, {
|
|
1856
|
+
'last_permission_request': int(time.time()),
|
|
1857
|
+
'notification_message': hook_data.get('message', ''),
|
|
1858
|
+
'session_id': hook_data.get('session_id', ''),
|
|
1859
|
+
'transcript_path': transcript_path,
|
|
1860
|
+
'conversation_uuid': conversation_uuid or 'unknown',
|
|
1861
|
+
'directory': str(Path.cwd())
|
|
1862
|
+
})
|
|
1863
|
+
|
|
1864
|
+
check_and_show_first_use_help(instance_name)
|
|
1865
|
+
|
|
1866
|
+
except Exception:
|
|
1867
|
+
pass
|
|
1868
|
+
|
|
1869
|
+
sys.exit(EXIT_SUCCESS)
|
|
1870
|
+
|
|
1871
|
+
# ==================== Main Entry Point ====================
|
|
1872
|
+
|
|
1873
|
+
def main(argv=None):
|
|
1874
|
+
"""Main command dispatcher"""
|
|
1875
|
+
if argv is None:
|
|
1876
|
+
argv = sys.argv
|
|
1877
|
+
|
|
1878
|
+
if len(argv) < 2:
|
|
1879
|
+
return cmd_help()
|
|
1880
|
+
|
|
1881
|
+
cmd = argv[1]
|
|
1882
|
+
|
|
1883
|
+
# Main commands
|
|
1884
|
+
if cmd == 'help' or cmd == '--help':
|
|
1885
|
+
return cmd_help()
|
|
1886
|
+
elif cmd == 'open':
|
|
1887
|
+
return cmd_open(*argv[2:])
|
|
1888
|
+
elif cmd == 'watch':
|
|
1889
|
+
return cmd_watch(*argv[2:])
|
|
1890
|
+
elif cmd == 'clear':
|
|
1891
|
+
return cmd_clear()
|
|
1892
|
+
elif cmd == 'cleanup':
|
|
1893
|
+
return cmd_cleanup(*argv[2:])
|
|
1894
|
+
elif cmd == 'send':
|
|
1895
|
+
if len(argv) < 3:
|
|
1896
|
+
print(format_error("Message required"), file=sys.stderr)
|
|
1897
|
+
return 1
|
|
1898
|
+
return cmd_send(argv[2])
|
|
1899
|
+
|
|
1900
|
+
# Hook commands
|
|
1901
|
+
elif cmd == 'post':
|
|
1902
|
+
handle_hook_post()
|
|
1903
|
+
return 0
|
|
1904
|
+
elif cmd == 'stop':
|
|
1905
|
+
handle_hook_stop()
|
|
1906
|
+
return 0
|
|
1907
|
+
elif cmd == 'notify':
|
|
1908
|
+
handle_hook_notification()
|
|
1909
|
+
return 0
|
|
1910
|
+
|
|
1911
|
+
# Unknown command
|
|
1912
|
+
else:
|
|
1913
|
+
print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
|
|
1914
|
+
return 1
|
|
1915
|
+
|
|
1916
|
+
if __name__ == '__main__':
|
|
1917
|
+
sys.exit(main())
|