hcom 0.2.2__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hcom might be problematic. Click here for more details.

hcom/__main__.py CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- hcom 0.2.2
3
+ hcom 0.3.0
4
4
  CLI tool for launching multiple Claude Code terminals with interactive subagents, headless persistence, and real-time communication via hooks
5
5
  """
6
6
 
7
7
  import os
8
8
  import sys
9
9
  import json
10
+ import io
10
11
  import tempfile
11
12
  import shutil
12
13
  import shlex
@@ -18,6 +19,11 @@ import platform
18
19
  import random
19
20
  from pathlib import Path
20
21
  from datetime import datetime, timedelta
22
+ from typing import Optional, Any, NamedTuple
23
+ from dataclasses import dataclass, asdict, field
24
+
25
+ if sys.version_info < (3, 10):
26
+ sys.exit("Error: hcom requires Python 3.10 or higher")
21
27
 
22
28
  # ==================== Constants ====================
23
29
 
@@ -30,7 +36,7 @@ def is_wsl():
30
36
  try:
31
37
  with open('/proc/version', 'r') as f:
32
38
  return 'microsoft' in f.read().lower()
33
- except:
39
+ except (FileNotFoundError, PermissionError, OSError):
34
40
  return False
35
41
 
36
42
  def is_termux():
@@ -38,7 +44,7 @@ def is_termux():
38
44
  return (
39
45
  'TERMUX_VERSION' in os.environ or # Primary: Works all versions
40
46
  'TERMUX__ROOTFS' in os.environ or # Modern: v0.119.0+
41
- os.path.exists('/data/data/com.termux') or # Fallback: Path check
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.05 # 50ms between stop hook polls
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]
@@ -93,6 +95,7 @@ def get_windows_kernel32():
93
95
  return _windows_kernel32_cache
94
96
 
95
97
  MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
98
+ AGENT_NAME_PATTERN = re.compile(r'^[a-z-]+$')
96
99
  TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
97
100
 
98
101
  RESET = "\033[0m"
@@ -107,25 +110,38 @@ BG_GREEN = "\033[42m"
107
110
  BG_CYAN = "\033[46m"
108
111
  BG_YELLOW = "\033[43m"
109
112
  BG_RED = "\033[41m"
113
+ BG_GRAY = "\033[100m"
110
114
 
111
115
  STATUS_MAP = {
112
- "thinking": (BG_CYAN, "◉"),
113
- "responding": (BG_GREEN, "▷"),
114
- "executing": (BG_GREEN, "▶"),
115
116
  "waiting": (BG_BLUE, "◉"),
117
+ "delivered": (BG_CYAN, "▷"),
118
+ "active": (BG_GREEN, "▶"),
116
119
  "blocked": (BG_YELLOW, "■"),
117
- "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'),
118
135
  }
119
136
 
120
137
  # ==================== Windows/WSL Console Unicode ====================
121
- import io
122
138
 
123
139
  # Apply UTF-8 encoding for Windows and WSL
124
140
  if IS_WINDOWS or is_wsl():
125
141
  try:
126
142
  sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
127
143
  sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
128
- except:
144
+ except (AttributeError, OSError):
129
145
  pass # Fallback if stream redirection fails
130
146
 
131
147
  # ==================== Error Handling Strategy ====================
@@ -134,39 +150,51 @@ if IS_WINDOWS or is_wsl():
134
150
  # Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
135
151
  # Pattern: Try/except/return False in hooks, raise in CLI operations.
136
152
 
137
- # ==================== Configuration ====================
138
-
139
- DEFAULT_CONFIG = {
140
- "terminal_command": None,
141
- "terminal_mode": "new_window",
142
- "initial_prompt": "Say hi in chat",
143
- "sender_name": "bigboss",
144
- "sender_emoji": "🐳",
145
- "cli_hints": "",
146
- "wait_timeout": 1800, # 30mins
147
- "max_message_size": 1048576, # 1MB
148
- "max_messages_per_delivery": 50,
149
- "first_use_text": "Essential, concise messages only, say hi in hcom chat now",
150
- "instance_hints": "",
151
- "env_overrides": {},
152
- "auto_watch": True # Auto-launch watch dashboard after open
153
- }
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
+
170
+ # ==================== Config Defaults ====================
171
+
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()
154
190
 
155
191
  _config = None
156
192
 
193
+ # Generate env var mappings from dataclass fields (except env_overrides)
157
194
  HOOK_SETTINGS = {
158
- 'wait_timeout': 'HCOM_WAIT_TIMEOUT',
159
- 'max_message_size': 'HCOM_MAX_MESSAGE_SIZE',
160
- 'max_messages_per_delivery': 'HCOM_MAX_MESSAGES_PER_DELIVERY',
161
- 'first_use_text': 'HCOM_FIRST_USE_TEXT',
162
- 'instance_hints': 'HCOM_INSTANCE_HINTS',
163
- 'sender_name': 'HCOM_SENDER_NAME',
164
- 'sender_emoji': 'HCOM_SENDER_EMOJI',
165
- 'cli_hints': 'HCOM_CLI_HINTS',
166
- 'terminal_mode': 'HCOM_TERMINAL_MODE',
167
- 'terminal_command': 'HCOM_TERMINAL_COMMAND',
168
- 'initial_prompt': 'HCOM_INITIAL_PROMPT',
169
- 'auto_watch': 'HCOM_AUTO_WATCH'
195
+ field: f"HCOM_{field.upper()}"
196
+ for field in DEFAULT_CONFIG.__dataclass_fields__
197
+ if field != 'env_overrides'
170
198
  }
171
199
 
172
200
  # Path constants
@@ -179,7 +207,7 @@ ARCHIVE_DIR = "archive"
179
207
 
180
208
  # ==================== File System Utilities ====================
181
209
 
182
- def hcom_path(*parts, ensure_parent=False):
210
+ def hcom_path(*parts: str, ensure_parent: bool = False) -> Path:
183
211
  """Build path under ~/.hcom"""
184
212
  path = Path.home() / ".hcom"
185
213
  if parts:
@@ -188,8 +216,8 @@ def hcom_path(*parts, ensure_parent=False):
188
216
  path.parent.mkdir(parents=True, exist_ok=True)
189
217
  return path
190
218
 
191
- def atomic_write(filepath, content):
192
- """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."""
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."""
193
221
  filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
194
222
 
195
223
  for attempt in range(3):
@@ -207,18 +235,20 @@ def atomic_write(filepath, content):
207
235
  continue
208
236
  else:
209
237
  try: # Clean up temp file on final failure
210
- os.unlink(tmp.name)
211
- except:
238
+ Path(tmp.name).unlink()
239
+ except (FileNotFoundError, PermissionError, OSError):
212
240
  pass
213
241
  return False
214
242
  except Exception:
215
243
  try: # Clean up temp file on any other error
216
244
  os.unlink(tmp.name)
217
- except:
245
+ except (FileNotFoundError, PermissionError, OSError):
218
246
  pass
219
247
  return False
220
248
 
221
- def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
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:
222
252
  """Read file with retry logic for Windows file locking"""
223
253
  if not Path(filepath).exists():
224
254
  return default
@@ -227,7 +257,7 @@ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
227
257
  try:
228
258
  with open(filepath, 'r', encoding='utf-8') as f:
229
259
  return read_func(f)
230
- except PermissionError as e:
260
+ except PermissionError:
231
261
  # Only retry on Windows (file locking issue)
232
262
  if IS_WINDOWS and attempt < max_retries - 1:
233
263
  time.sleep(FILE_RETRY_DELAY)
@@ -241,30 +271,18 @@ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
241
271
 
242
272
  return default
243
273
 
244
- def get_instance_file(instance_name):
245
- """Get path to instance's position file"""
246
- return hcom_path(INSTANCES_DIR, f"{instance_name}.json")
247
-
248
- def migrate_instance_data_v020(data, instance_name):
249
- """One-time migration from v0.2.0 format (remove in v0.3.0)"""
250
- needs_save = False
251
-
252
- # Convert single session_id to session_ids array
253
- if 'session_ids' not in data and 'session_id' in data and data['session_id']:
254
- data['session_ids'] = [data['session_id']]
255
- needs_save = True
256
-
257
- # Remove conversation_uuid - no longer used anywhere
258
- if 'conversation_uuid' in data:
259
- del data['conversation_uuid']
260
- needs_save = True
261
-
262
- if needs_save:
263
- save_instance_position(instance_name, data)
274
+ def get_instance_file(instance_name: str) -> Path:
275
+ """Get path to instance's position file with path traversal protection"""
276
+ # Sanitize instance name to prevent directory traversal
277
+ if not instance_name:
278
+ instance_name = "unknown"
279
+ safe_name = instance_name.replace('..', '').replace('/', '-').replace('\\', '-').replace(os.sep, '-')
280
+ if not safe_name:
281
+ safe_name = "unknown"
264
282
 
265
- return data
283
+ return hcom_path(INSTANCES_DIR, f"{safe_name}.json")
266
284
 
267
- def load_instance_position(instance_name):
285
+ def load_instance_position(instance_name: str) -> dict[str, Any]:
268
286
  """Load position data for a single instance"""
269
287
  instance_file = get_instance_file(instance_name)
270
288
 
@@ -274,21 +292,17 @@ def load_instance_position(instance_name):
274
292
  default={}
275
293
  )
276
294
 
277
- # Apply migration if needed
278
- if data:
279
- data = migrate_instance_data_v020(data, instance_name)
280
-
281
295
  return data
282
296
 
283
- def save_instance_position(instance_name, data):
297
+ def save_instance_position(instance_name: str, data: dict[str, Any]) -> bool:
284
298
  """Save position data for a single instance. Returns True on success, False on failure."""
285
299
  try:
286
300
  instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json", ensure_parent=True)
287
301
  return atomic_write(instance_file, json.dumps(data, indent=2))
288
- except:
302
+ except (OSError, PermissionError, ValueError):
289
303
  return False
290
304
 
291
- def load_all_positions():
305
+ def load_all_positions() -> dict[str, dict[str, Any]]:
292
306
  """Load positions from all instance files"""
293
307
  instances_dir = hcom_path(INSTANCES_DIR)
294
308
  if not instances_dir.exists():
@@ -306,31 +320,18 @@ def load_all_positions():
306
320
  positions[instance_name] = data
307
321
  return positions
308
322
 
309
- def clear_all_positions():
310
- """Clear all instance position files"""
323
+ def clear_all_positions() -> None:
324
+ """Clear all instance position files and related mapping files"""
311
325
  instances_dir = hcom_path(INSTANCES_DIR)
312
326
  if instances_dir.exists():
313
327
  for f in instances_dir.glob('*.json'):
314
328
  f.unlink()
329
+ # Clean up orphaned mapping files
330
+ for f in instances_dir.glob('.launch_map_*'):
331
+ f.unlink(missing_ok=True)
315
332
  else:
316
333
  instances_dir.mkdir(exist_ok=True)
317
334
 
318
- # ==================== Hook Utilities ====================
319
-
320
- HCOM_FORMAT_INSTRUCTIONS = """\n\nREQUIRED: Display HCOM 'reason' content using this format with markdown:
321
- ═════════ HCOM ═════════
322
- ***[✓ Sent] [new message] and/or [all other info in square brackets]***
323
- **name → name:** *message content*
324
- ════════════════════════
325
- Always show exact full content of every single message without saying why you are displaying it. This format is mandatory for system functionality"""
326
-
327
- def emit_hook_response(reason, decision=HOOK_DECISION_BLOCK, exit_code=EXIT_BLOCK):
328
- """Emit standardized hook JSON response"""
329
- output = {"decision": decision, "reason": reason} if decision else {"reason": reason}
330
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
331
- sys.exit(exit_code)
332
-
333
-
334
335
  # ==================== Configuration System ====================
335
336
 
336
337
  def get_cached_config():
@@ -340,33 +341,31 @@ def get_cached_config():
340
341
  _config = _load_config_from_file()
341
342
  return _config
342
343
 
343
- def _load_config_from_file():
344
- """Actually load configuration from ~/.hcom/config.json"""
345
- import copy
344
+ def _load_config_from_file() -> dict:
345
+ """Load configuration from ~/.hcom/config.json"""
346
346
  config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
347
347
 
348
- config = copy.deepcopy(DEFAULT_CONFIG)
348
+ # Start with default config as dict
349
+ config_dict = asdict(DEFAULT_CONFIG)
349
350
 
350
351
  try:
351
- user_config = read_file_with_retry(
352
+ if user_config := read_file_with_retry(
352
353
  config_path,
353
354
  lambda f: json.load(f),
354
355
  default=None
355
- )
356
- if user_config:
357
- for key, value in user_config.items():
358
- if key == 'env_overrides':
359
- config['env_overrides'].update(value)
360
- else:
361
- config[key] = value
356
+ ):
357
+ # Merge user config into default config
358
+ config_dict.update(user_config)
362
359
  elif not config_path.exists():
363
- atomic_write(config_path, json.dumps(DEFAULT_CONFIG, indent=2))
360
+ # Write default config if file doesn't exist
361
+ atomic_write(config_path, json.dumps(config_dict, indent=2))
364
362
  except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
365
363
  print("Warning: Cannot read config file, using defaults", file=sys.stderr)
364
+ # config_dict already has defaults
366
365
 
367
- return config
366
+ return config_dict
368
367
 
369
- def get_config_value(key, default=None):
368
+ def get_config_value(key: str, default: Any = None) -> Any:
370
369
  """Get config value with proper precedence:
371
370
  1. Environment variable (if in HOOK_SETTINGS)
372
371
  2. Config file
@@ -374,16 +373,18 @@ def get_config_value(key, default=None):
374
373
  """
375
374
  if key in HOOK_SETTINGS:
376
375
  env_var = HOOK_SETTINGS[key]
377
- env_value = os.environ.get(env_var)
378
- if env_value is not None:
376
+ if (env_value := os.environ.get(env_var)) is not None:
377
+ # Type conversion based on key
379
378
  if key in ['wait_timeout', 'max_message_size', 'max_messages_per_delivery']:
380
379
  try:
381
380
  return int(env_value)
382
381
  except ValueError:
382
+ # Invalid integer - fall through to config/default
383
383
  pass
384
- elif key == 'auto_watch': # Convert string to boolean
384
+ elif key == 'auto_watch':
385
385
  return env_value.lower() in ('true', '1', 'yes', 'on')
386
386
  else:
387
+ # String values - return as-is
387
388
  return env_value
388
389
 
389
390
  config = get_cached_config()
@@ -396,7 +397,7 @@ def get_hook_command():
396
397
  Both approaches exit silently (code 0) when not launched via 'hcom open'.
397
398
  """
398
399
  python_path = sys.executable
399
- script_path = os.path.abspath(__file__)
400
+ script_path = str(Path(__file__).resolve())
400
401
 
401
402
  if IS_WINDOWS:
402
403
  # Windows cmd.exe syntax - no parentheses so arguments append correctly
@@ -412,6 +413,15 @@ def get_hook_command():
412
413
  # Unix clean paths: use environment variable
413
414
  return '${HCOM:-true}', {}
414
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
+
415
425
  def build_claude_env():
416
426
  """Build environment variables for Claude instances"""
417
427
  env = {HCOM_ACTIVE_ENV: HCOM_ACTIVE_VALUE}
@@ -433,7 +443,7 @@ def build_claude_env():
433
443
 
434
444
  # Set HCOM only for clean paths (spaces handled differently)
435
445
  python_path = sys.executable
436
- script_path = os.path.abspath(__file__)
446
+ script_path = str(Path(__file__).resolve())
437
447
  if ' ' not in python_path and ' ' not in script_path:
438
448
  env['HCOM'] = f'{python_path} {script_path}'
439
449
 
@@ -441,8 +451,8 @@ def build_claude_env():
441
451
 
442
452
  # ==================== Message System ====================
443
453
 
444
- def validate_message(message):
445
- """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."""
446
456
  if not message or not message.strip():
447
457
  return format_error("Message required")
448
458
 
@@ -456,7 +466,7 @@ def validate_message(message):
456
466
 
457
467
  return None
458
468
 
459
- def send_message(from_instance, message):
469
+ def send_message(from_instance: str, message: str) -> bool:
460
470
  """Send a message to the log"""
461
471
  try:
462
472
  log_file = hcom_path(LOG_FILE, ensure_parent=True)
@@ -475,7 +485,7 @@ def send_message(from_instance, message):
475
485
  except Exception:
476
486
  return False
477
487
 
478
- 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:
479
489
  """Check if message should be delivered based on @-mentions"""
480
490
  text = msg['message']
481
491
 
@@ -509,9 +519,9 @@ def should_deliver_message(msg, instance_name, all_instance_names=None):
509
519
 
510
520
  return False # This instance doesn't match, but others might
511
521
 
512
- # ==================== Parsing and Helper Functions ====================
522
+ # ==================== Parsing & Utilities ====================
513
523
 
514
- def parse_open_args(args):
524
+ def parse_open_args(args: list[str]) -> tuple[list[str], Optional[str], list[str], bool]:
515
525
  """Parse arguments for open command
516
526
 
517
527
  Returns:
@@ -566,7 +576,7 @@ def parse_open_args(args):
566
576
 
567
577
  return instances, prefix, claude_args, background
568
578
 
569
- def extract_agent_config(content):
579
+ def extract_agent_config(content: str) -> dict[str, str]:
570
580
  """Extract configuration from agent YAML frontmatter"""
571
581
  if not content.startswith('---'):
572
582
  return {}
@@ -580,49 +590,89 @@ def extract_agent_config(content):
580
590
  config = {}
581
591
 
582
592
  # Extract model field
583
- model_match = re.search(r'^model:\s*(.+)$', yaml_section, re.MULTILINE)
584
- if model_match:
593
+ if model_match := re.search(r'^model:\s*(.+)$', yaml_section, re.MULTILINE):
585
594
  value = model_match.group(1).strip()
586
595
  if value and value.lower() != 'inherit':
587
596
  config['model'] = value
588
-
597
+
589
598
  # Extract tools field
590
- tools_match = re.search(r'^tools:\s*(.+)$', yaml_section, re.MULTILINE)
591
- if tools_match:
599
+ if tools_match := re.search(r'^tools:\s*(.+)$', yaml_section, re.MULTILINE):
592
600
  value = tools_match.group(1).strip()
593
601
  if value:
594
602
  config['tools'] = value.replace(', ', ',')
595
603
 
596
604
  return config
597
605
 
598
- def resolve_agent(name):
599
- """Resolve agent file by name
600
-
606
+ def resolve_agent(name: str) -> tuple[str, dict[str, str]]:
607
+ """Resolve agent file by name with validation.
608
+
601
609
  Looks for agent files in:
602
610
  1. .claude/agents/{name}.md (local)
603
611
  2. ~/.claude/agents/{name}.md (global)
604
-
605
- Returns tuple: (content after stripping YAML frontmatter, config dict)
612
+
613
+ Returns tuple: (content without YAML frontmatter, config dict)
606
614
  """
607
- for base_path in [Path.cwd(), Path.home()]:
608
- agent_path = base_path / '.claude/agents' / f'{name}.md'
609
- if agent_path.exists():
610
- content = read_file_with_retry(
611
- agent_path,
612
- lambda f: f.read(),
613
- default=None
614
- )
615
- if content is None:
616
- continue # Skip to next base_path if read failed
617
- config = extract_agent_config(content)
618
- stripped = strip_frontmatter(content)
619
- if not stripped.strip():
620
- raise ValueError(format_error(f"Agent '{name}' has empty content", 'Check the agent file is a valid format and contains text'))
621
- return stripped, config
622
-
623
- raise FileNotFoundError(format_error(f'Agent "{name}" not found in project or user .claude/agents/ folder', 'Check available agents or create the agent file'))
624
-
625
- def strip_frontmatter(content):
615
+ hint = 'Agent names must use lowercase letters and dashes only'
616
+
617
+ if not isinstance(name, str):
618
+ raise FileNotFoundError(format_error(
619
+ f"Agent '{name}' not found",
620
+ hint
621
+ ))
622
+
623
+ candidate = name.strip()
624
+ display_name = candidate or name
625
+
626
+ if not candidate or not AGENT_NAME_PATTERN.fullmatch(candidate):
627
+ raise FileNotFoundError(format_error(
628
+ f"Agent '{display_name}' not found",
629
+ hint
630
+ ))
631
+
632
+ for base_path in (Path.cwd(), Path.home()):
633
+ agents_dir = base_path / '.claude' / 'agents'
634
+ try:
635
+ agents_dir_resolved = agents_dir.resolve(strict=True)
636
+ except FileNotFoundError:
637
+ continue
638
+
639
+ agent_path = agents_dir / f'{candidate}.md'
640
+ if not agent_path.exists():
641
+ continue
642
+
643
+ try:
644
+ resolved_agent_path = agent_path.resolve(strict=True)
645
+ except FileNotFoundError:
646
+ continue
647
+
648
+ try:
649
+ resolved_agent_path.relative_to(agents_dir_resolved)
650
+ except ValueError:
651
+ continue
652
+
653
+ content = read_file_with_retry(
654
+ agent_path,
655
+ lambda f: f.read(),
656
+ default=None
657
+ )
658
+ if content is None:
659
+ continue
660
+
661
+ config = extract_agent_config(content)
662
+ stripped = strip_frontmatter(content)
663
+ if not stripped.strip():
664
+ raise ValueError(format_error(
665
+ f"Agent '{candidate}' has empty content",
666
+ 'Check the agent file is a valid format and contains text'
667
+ ))
668
+ return stripped, config
669
+
670
+ raise FileNotFoundError(format_error(
671
+ f"Agent '{candidate}' not found in project or user .claude/agents/ folder",
672
+ 'Check available agents or create the agent file'
673
+ ))
674
+
675
+ def strip_frontmatter(content: str) -> str:
626
676
  """Strip YAML frontmatter from agent file"""
627
677
  if content.startswith('---'):
628
678
  # Find the closing --- on its own line
@@ -632,7 +682,7 @@ def strip_frontmatter(content):
632
682
  return '\n'.join(lines[i+1:]).strip()
633
683
  return content
634
684
 
635
- def get_display_name(session_id, prefix=None):
685
+ def get_display_name(session_id: Optional[str], prefix: Optional[str] = None) -> str:
636
686
  """Get display name for instance using session_id"""
637
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']
638
688
  # Phonetic letters (5 per syllable, matches syls order)
@@ -654,24 +704,27 @@ def get_display_name(session_id, prefix=None):
654
704
  hex_char = session_id[0] if session_id else 'x'
655
705
  base_name = f"{dir_char}{syllable}{letter}{hex_char}"
656
706
 
657
- # Collision detection: if taken by another PID, use more session_id chars
707
+ # Collision detection: if taken by different session_id, use more chars
658
708
  instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
659
709
  if instance_file.exists():
660
710
  try:
661
711
  with open(instance_file, 'r', encoding='utf-8') as f:
662
712
  data = json.load(f)
663
- their_pid = data.get('pid')
664
- our_pid = os.getppid()
665
- # Only consider it a collision if they have a PID and it's different
666
- if their_pid and their_pid != our_pid:
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:
667
718
  # Use first 4 chars of session_id for collision resolution
668
719
  base_name = f"{dir_char}{session_id[0:4]}"
669
- except:
670
- pass
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
671
725
  else:
672
- # Fallback to PID-based naming if no session_id
673
- pid_suffix = os.getppid() % 10000
674
- base_name = f"{dir_char}{pid_suffix}claude"
726
+ # session_id is required - fail gracefully
727
+ raise ValueError("session_id required for instance naming")
675
728
 
676
729
  if prefix:
677
730
  return f"{prefix}-{base_name}"
@@ -688,31 +741,27 @@ def _remove_hcom_hooks_from_settings(settings):
688
741
  import copy
689
742
 
690
743
  # Patterns to match any hcom hook command
691
- # - $HCOM post/stop/notify
692
- # - ${HCOM:-...} post/stop/notify
744
+ # Modern hooks (patterns 1-2, 7): Match all hook types via env var or wrapper
745
+ # - ${HCOM:-true} sessionstart/pre/stop/notify/userpromptsubmit
693
746
  # - [ "${HCOM_ACTIVE}" = "1" ] && ... hcom.py ... || true
694
- # - hcom post/stop/notify
695
- # - uvx hcom post/stop/notify
696
- # - /path/to/hcom.py post/stop/notify
697
747
  # - sh -c "[ ... ] && ... hcom ..."
698
- # - "/path with spaces/python" "/path with spaces/hcom.py" post/stop/notify
699
- # - '/path/to/python' '/path/to/hcom.py' post/stop/notify
700
- # Note: Modern hooks use either ${HCOM:-true} (pattern 1) or the HCOM_ACTIVE conditional
701
- # with full paths (pattern 2), both of which match all hook types including pre/sessionstart.
702
- # The (post|stop|notify) patterns (3-6) are for older direct command formats that didn't
703
- # 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
704
752
  hcom_patterns = [
705
753
  r'\$\{?HCOM', # Environment variable (${HCOM:-true}) - all hook types
706
754
  r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with full path - all hook types
707
- r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command (older format)
708
- r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command (older format)
709
- r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote (older format)
710
- r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b(?=\s|$)', # Quoted path (older format)
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
711
759
  r'sh\s+-c.*hcom', # Shell wrapper with hcom
712
760
  ]
713
761
  compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
714
-
715
- for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
762
+
763
+ # Check all hook types including PostToolUse for backward compatibility cleanup
764
+ for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
716
765
  if event not in settings['hooks']:
717
766
  continue
718
767
 
@@ -758,7 +807,7 @@ def build_env_string(env_vars, format_type="bash"):
758
807
  return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
759
808
 
760
809
 
761
- def format_error(message, suggestion=None):
810
+ def format_error(message: str, suggestion: Optional[str] = None) -> str:
762
811
  """Format error message consistently"""
763
812
  base = f"Error: {message}"
764
813
  if suggestion:
@@ -773,7 +822,7 @@ def has_claude_arg(claude_args, arg_names, arg_prefixes):
773
822
  for arg in claude_args
774
823
  )
775
824
 
776
- 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]]:
777
826
  """Build Claude command with proper argument handling
778
827
  Returns tuple: (command_string, temp_file_path_or_none)
779
828
  For agent content, writes to temp file and uses cat to read it.
@@ -850,7 +899,7 @@ def create_bash_script(script_file, env, cwd, command_str, background=False):
850
899
  paths_to_add = []
851
900
  for p in [node_path, claude_path]:
852
901
  if p:
853
- dir_path = os.path.dirname(os.path.realpath(p))
902
+ dir_path = str(Path(p).resolve().parent)
854
903
  if dir_path not in paths_to_add:
855
904
  paths_to_add.append(dir_path)
856
905
 
@@ -907,17 +956,17 @@ def find_bash_on_windows():
907
956
  os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
908
957
  if base:
909
958
  candidates.extend([
910
- os.path.join(base, 'Git', 'usr', 'bin', 'bash.exe'), # usr/bin is more common
911
- os.path.join(base, 'Git', 'bin', 'bash.exe')
959
+ str(Path(base) / 'Git' / 'usr' / 'bin' / 'bash.exe'), # usr/bin is more common
960
+ str(Path(base) / 'Git' / 'bin' / 'bash.exe')
912
961
  ])
913
962
 
914
963
  # 2. Portable Git installation
915
964
  local_appdata = os.environ.get('LOCALAPPDATA', '')
916
965
  if local_appdata:
917
- git_portable = os.path.join(local_appdata, 'Programs', 'Git')
966
+ git_portable = Path(local_appdata) / 'Programs' / 'Git'
918
967
  candidates.extend([
919
- os.path.join(git_portable, 'usr', 'bin', 'bash.exe'),
920
- os.path.join(git_portable, 'bin', 'bash.exe')
968
+ str(git_portable / 'usr' / 'bin' / 'bash.exe'),
969
+ str(git_portable / 'bin' / 'bash.exe')
921
970
  ])
922
971
 
923
972
  # 3. PATH bash (if not WSL's launcher)
@@ -935,7 +984,7 @@ def find_bash_on_windows():
935
984
 
936
985
  # Find first existing bash
937
986
  for bash in candidates:
938
- if bash and os.path.exists(bash):
987
+ if bash and Path(bash).exists():
939
988
  return bash
940
989
 
941
990
  return None
@@ -976,20 +1025,23 @@ def get_linux_terminal_argv():
976
1025
 
977
1026
  def windows_hidden_popen(argv, *, env=None, cwd=None, stdout=None):
978
1027
  """Create hidden Windows process without console window."""
979
- startupinfo = subprocess.STARTUPINFO()
980
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
981
- startupinfo.wShowWindow = subprocess.SW_HIDE
982
-
983
- return subprocess.Popen(
984
- argv,
985
- env=env,
986
- cwd=cwd,
987
- stdin=subprocess.DEVNULL,
988
- stdout=stdout,
989
- stderr=subprocess.STDOUT,
990
- startupinfo=startupinfo,
991
- creationflags=CREATE_NO_WINDOW
992
- )
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")
993
1045
 
994
1046
  # Platform dispatch map
995
1047
  PLATFORM_TERMINAL_GETTERS = {
@@ -1036,18 +1088,14 @@ def _parse_terminal_command(template, script_file):
1036
1088
 
1037
1089
  return replaced
1038
1090
 
1039
- def launch_terminal(command, env, config=None, cwd=None, background=False):
1091
+ def launch_terminal(command, env, cwd=None, background=False):
1040
1092
  """Launch terminal with command using unified script-first approach
1041
1093
  Args:
1042
1094
  command: Command string from build_claude_command
1043
1095
  env: Environment variables to set
1044
- config: Configuration dict
1045
1096
  cwd: Working directory
1046
1097
  background: Launch as background process
1047
1098
  """
1048
- if config is None:
1049
- config = get_cached_config()
1050
-
1051
1099
  env_vars = os.environ.copy()
1052
1100
  env_vars.update(env)
1053
1101
  command_str = command
@@ -1113,7 +1161,7 @@ def launch_terminal(command, env, config=None, cwd=None, background=False):
1113
1161
  script_content = f.read()
1114
1162
  print(f"# Script: {script_file}")
1115
1163
  print(script_content)
1116
- os.unlink(script_file) # Clean up immediately
1164
+ Path(script_file).unlink() # Clean up immediately
1117
1165
  return True
1118
1166
  except Exception as e:
1119
1167
  print(format_error(f"Failed to read script: {e}"), file=sys.stderr)
@@ -1227,11 +1275,12 @@ def setup_hooks():
1227
1275
  # Get wait_timeout (needed for Stop hook)
1228
1276
  wait_timeout = get_config_value('wait_timeout', 1800)
1229
1277
 
1230
- # Define all hooks
1278
+ # Define all hooks (PostToolUse removed - causes API 400 errors)
1231
1279
  hook_configs = [
1232
1280
  ('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
1281
+ ('UserPromptSubmit', '', f'{hook_cmd_base} userpromptsubmit', None),
1233
1282
  ('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
1234
- ('PostToolUse', '.*', f'{hook_cmd_base} post', None),
1283
+ # ('PostToolUse', '.*', f'{hook_cmd_base} post', None), # DISABLED
1235
1284
  ('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
1236
1285
  ('Notification', '', f'{hook_cmd_base} notify', None),
1237
1286
  ]
@@ -1275,9 +1324,9 @@ def verify_hooks_installed(settings_path):
1275
1324
  if not settings:
1276
1325
  return False
1277
1326
 
1278
- # Check all hook types exist with HCOM commands
1327
+ # Check all hook types exist with HCOM commands (PostToolUse removed)
1279
1328
  hooks = settings.get('hooks', {})
1280
- for hook_type in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
1329
+ for hook_type in ['SessionStart', 'PreToolUse', 'Stop', 'Notification']:
1281
1330
  if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
1282
1331
  for h in hooks.get(hook_type, [])):
1283
1332
  return False
@@ -1313,7 +1362,7 @@ def is_process_alive(pid):
1313
1362
 
1314
1363
  try:
1315
1364
  pid = int(pid)
1316
- except (TypeError, ValueError) as e:
1365
+ except (TypeError, ValueError):
1317
1366
  return False
1318
1367
 
1319
1368
  if IS_WINDOWS:
@@ -1350,29 +1399,33 @@ def is_process_alive(pid):
1350
1399
 
1351
1400
  kernel32.CloseHandle(handle)
1352
1401
  return False # Couldn't get exit code
1353
- except Exception as e:
1402
+ except Exception:
1354
1403
  return False
1355
1404
  else:
1356
1405
  # Unix: Use os.kill with signal 0
1357
1406
  try:
1358
1407
  os.kill(pid, 0)
1359
1408
  return True
1360
- except ProcessLookupError as e:
1409
+ except ProcessLookupError:
1361
1410
  return False
1362
- except Exception as e:
1411
+ except Exception:
1363
1412
  return False
1364
1413
 
1365
- def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
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:
1366
1420
  """Parse messages from log file
1367
1421
  Args:
1368
1422
  log_file: Path to log file
1369
1423
  start_pos: Position to start reading from
1370
- return_end_pos: If True, return tuple (messages, end_position)
1371
1424
  Returns:
1372
- list of messages, or (messages, end_pos) if return_end_pos=True
1425
+ LogParseResult containing messages and end position
1373
1426
  """
1374
1427
  if not log_file.exists():
1375
- return ([], start_pos) if return_end_pos else []
1428
+ return LogParseResult([], start_pos)
1376
1429
 
1377
1430
  def read_messages(f):
1378
1431
  f.seek(start_pos)
@@ -1380,7 +1433,7 @@ def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
1380
1433
  end_pos = f.tell() # Capture actual end position
1381
1434
 
1382
1435
  if not content.strip():
1383
- return ([], end_pos)
1436
+ return LogParseResult([], end_pos)
1384
1437
 
1385
1438
  messages = []
1386
1439
  message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
@@ -1398,18 +1451,20 @@ def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
1398
1451
  'message': message.replace('\\|', '|')
1399
1452
  })
1400
1453
 
1401
- return (messages, end_pos)
1454
+ return LogParseResult(messages, end_pos)
1402
1455
 
1403
- result = read_file_with_retry(
1456
+ return read_file_with_retry(
1404
1457
  log_file,
1405
1458
  read_messages,
1406
- default=([], start_pos)
1459
+ default=LogParseResult([], start_pos)
1407
1460
  )
1408
1461
 
1409
- return result if return_end_pos else result[0]
1410
-
1411
- def get_new_messages(instance_name):
1412
- """Get new messages for instance with @-mention filtering"""
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
+ """
1413
1468
  log_file = hcom_path(LOG_FILE, ensure_parent=True)
1414
1469
 
1415
1470
  if not log_file.exists():
@@ -1424,7 +1479,8 @@ def get_new_messages(instance_name):
1424
1479
  last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
1425
1480
 
1426
1481
  # Atomic read with position tracking
1427
- all_messages, new_pos = parse_log_messages(log_file, last_pos, return_end_pos=True)
1482
+ result = parse_log_messages(log_file, last_pos)
1483
+ all_messages, new_pos = result.messages, result.end_position
1428
1484
 
1429
1485
  # Filter messages:
1430
1486
  # 1. Exclude own messages
@@ -1436,12 +1492,13 @@ def get_new_messages(instance_name):
1436
1492
  if should_deliver_message(msg, instance_name, all_instance_names):
1437
1493
  messages.append(msg)
1438
1494
 
1439
- # Update position to what was actually processed
1440
- update_instance_position(instance_name, {'pos': new_pos})
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})
1441
1498
 
1442
1499
  return messages
1443
1500
 
1444
- def format_age(seconds):
1501
+ def format_age(seconds: float) -> str:
1445
1502
  """Format time ago in human readable form"""
1446
1503
  if seconds < 60:
1447
1504
  return f"{int(seconds)}s"
@@ -1450,117 +1507,35 @@ def format_age(seconds):
1450
1507
  else:
1451
1508
  return f"{int(seconds/3600)}h"
1452
1509
 
1453
- def get_transcript_status(transcript_path):
1454
- """Parse transcript to determine current Claude state"""
1455
- if not transcript_path or not os.path.exists(transcript_path):
1456
- return "inactive", "", "", 0
1457
-
1458
- def read_status(f):
1459
- # Windows file buffering fix: read entire file to get current content
1460
- if IS_WINDOWS:
1461
- # Seek to beginning and read all content to bypass Windows file caching
1462
- f.seek(0)
1463
- all_content = f.read()
1464
- all_lines = all_content.strip().split('\n')
1465
- lines = all_lines[-5:] if len(all_lines) >= 5 else all_lines
1466
- else:
1467
- lines = f.readlines()[-5:]
1468
-
1469
- for i, line in enumerate(reversed(lines)):
1470
- try:
1471
- entry = json.loads(line)
1472
- timestamp = datetime.fromisoformat(entry['timestamp']).timestamp()
1473
- age = int(time.time() - timestamp)
1474
- entry_type = entry.get('type', '')
1475
-
1476
- if entry['type'] == 'system':
1477
- content = entry.get('content', '')
1478
- if 'Running' in content:
1479
- tool_name = content.split('Running ')[1].split('[')[0].strip()
1480
- return "executing", f"({format_age(age)})", tool_name, timestamp
1481
-
1482
- elif entry['type'] == 'assistant':
1483
- content = entry.get('content', [])
1484
- has_tool_use = any('tool_use' in str(item) for item in content)
1485
- if has_tool_use:
1486
- return "executing", f"({format_age(age)})", "tool", timestamp
1487
- else:
1488
- return "responding", f"({format_age(age)})", "", timestamp
1489
-
1490
- elif entry['type'] == 'user':
1491
- return "thinking", f"({format_age(age)})", "", timestamp
1492
- except (json.JSONDecodeError, KeyError, ValueError) as e:
1493
- continue
1494
-
1495
- return "inactive", "", "", 0
1496
-
1497
- try:
1498
- result = read_file_with_retry(
1499
- transcript_path,
1500
- read_status,
1501
- default=("inactive", "", "", 0)
1502
- )
1503
- return result
1504
- except Exception:
1505
- return "inactive", "", "", 0
1506
-
1507
- def get_instance_status(pos_data):
1508
- """Get current status of instance"""
1510
+ def get_instance_status(pos_data: dict[str, Any]) -> tuple[str, str]:
1511
+ """Get current status of instance. Returns (status_type, age_string)."""
1512
+ # Returns: (display_category, formatted_age) - category for color, age for display
1509
1513
  now = int(time.time())
1510
- wait_timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1511
-
1512
- # Check if process is still alive. pid: null means killed
1513
- # All real instances should have a PID (set by update_instance_with_pid)
1514
- if 'pid' in pos_data:
1515
- pid = pos_data['pid']
1516
- if pid is None:
1517
- # Explicitly null = was killed
1518
- return "inactive", ""
1519
- if not is_process_alive(pid):
1520
- # On Windows, PID checks can fail during process transitions
1521
- # Let timeout logic handle this using activity timestamps
1522
- wait_timeout = 30 if IS_WINDOWS else wait_timeout # Shorter timeout when PID dead
1523
-
1524
- last_permission = pos_data.get("last_permission_request", 0)
1525
- last_stop = pos_data.get("last_stop", 0)
1526
- last_tool = pos_data.get("last_tool", 0)
1527
-
1528
- transcript_timestamp = 0
1529
- transcript_status = "inactive"
1530
-
1531
- transcript_path = pos_data.get("transcript_path", "")
1532
- if transcript_path:
1533
- status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
1534
- transcript_status = status
1535
-
1536
- # Calculate last actual activity (excluding heartbeat)
1537
- last_activity = max(last_permission, last_tool, transcript_timestamp)
1538
1514
 
1539
- # Check timeout based on actual activity
1540
- if last_activity > 0 and (now - last_activity) > wait_timeout:
1515
+ # Check if killed
1516
+ if pos_data.get('pid') is None: #TODO: replace this later when process management stuff removed
1541
1517
  return "inactive", ""
1542
1518
 
1543
- # Now determine current status including heartbeat
1544
- events = [
1545
- (last_permission, "blocked"),
1546
- (last_stop, "waiting"),
1547
- (last_tool, "inactive"),
1548
- (transcript_timestamp, transcript_status)
1549
- ]
1519
+ # Get last known status
1520
+ last_status = pos_data.get('last_status', '')
1521
+ last_status_time = pos_data.get('last_status_time', 0)
1550
1522
 
1551
- recent_events = [(ts, status) for ts, status in events if ts > 0]
1552
- if not recent_events:
1553
- return "inactive", ""
1523
+ if not last_status or not last_status_time:
1524
+ return "unknown", ""
1554
1525
 
1555
- most_recent_time, most_recent_status = max(recent_events)
1556
- age = now - most_recent_time
1526
+ # Get display category from STATUS_INFO
1527
+ display_status, _ = STATUS_INFO.get(last_status, ('unknown', ''))
1557
1528
 
1558
- status_suffix = " (bg)" if pos_data.get('background') else ""
1559
- final_result = (most_recent_status, f"({format_age(age)}){status_suffix}")
1529
+ # Check timeout
1530
+ age = now - last_status_time
1531
+ timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1532
+ if age > timeout:
1533
+ return "inactive", ""
1560
1534
 
1561
- return final_result
1535
+ status_suffix = " (bg)" if pos_data.get('background') else ""
1536
+ return display_status, f"({format_age(age)}){status_suffix}"
1562
1537
 
1563
- def get_status_block(status_type):
1538
+ def get_status_block(status_type: str) -> str:
1564
1539
  """Get colored status block for a status type"""
1565
1540
  color, symbol = STATUS_MAP.get(status_type, (BG_RED, "?"))
1566
1541
  text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
@@ -1611,7 +1586,7 @@ def show_recent_activity_alt_screen(limit=None):
1611
1586
 
1612
1587
  log_file = hcom_path(LOG_FILE)
1613
1588
  if log_file.exists():
1614
- messages = parse_log_messages(log_file)
1589
+ messages = parse_log_messages(log_file).messages
1615
1590
  show_recent_messages(messages, limit, truncate=True)
1616
1591
 
1617
1592
  def show_instances_by_directory():
@@ -1634,11 +1609,19 @@ def show_instances_by_directory():
1634
1609
  for instance_name, pos_data in instances:
1635
1610
  status_type, age = get_instance_status(pos_data)
1636
1611
  status_block = get_status_block(status_type)
1637
- last_tool = pos_data.get("last_tool", 0)
1638
- last_tool_name = pos_data.get("last_tool_name", "unknown")
1639
- last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
1640
-
1641
- print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{RESET}")
1612
+
1613
+ # Format status description using STATUS_INFO and context
1614
+ last_status = pos_data.get('last_status', '')
1615
+ last_context = pos_data.get('last_status_context', '')
1616
+ _, desc_template = STATUS_INFO.get(last_status, ('unknown', ''))
1617
+
1618
+ # Format description with context if template has {}
1619
+ if '{}' in desc_template and last_context:
1620
+ status_desc = desc_template.format(last_context)
1621
+ else:
1622
+ status_desc = desc_template
1623
+
1624
+ print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_desc} {age}{RESET}")
1642
1625
  print()
1643
1626
  else:
1644
1627
  print(f" {DIM}Error reading instance data{RESET}")
@@ -1678,15 +1661,15 @@ def get_status_summary():
1678
1661
  if not positions:
1679
1662
  return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1680
1663
 
1681
- status_counts = {"thinking": 0, "responding": 0, "executing": 0, "waiting": 0, "blocked": 0, "inactive": 0}
1664
+ status_counts = {status: 0 for status in STATUS_MAP.keys()}
1682
1665
 
1683
- for instance_name, pos_data in positions.items():
1666
+ for _, pos_data in positions.items():
1684
1667
  status_type, _ = get_instance_status(pos_data)
1685
1668
  if status_type in status_counts:
1686
1669
  status_counts[status_type] += 1
1687
1670
 
1688
1671
  parts = []
1689
- status_order = ["thinking", "responding", "executing", "waiting", "blocked", "inactive"]
1672
+ status_order = ["active", "delivered", "waiting", "blocked", "inactive", "unknown"]
1690
1673
 
1691
1674
  for status_type in status_order:
1692
1675
  count = status_counts[status_type]
@@ -1721,11 +1704,8 @@ def initialize_instance_in_position_file(instance_name, session_id=None):
1721
1704
  defaults = {
1722
1705
  "pos": 0,
1723
1706
  "directory": str(Path.cwd()),
1724
- "last_tool": 0,
1725
- "last_tool_name": "unknown",
1726
1707
  "last_stop": 0,
1727
- "last_permission_request": 0,
1728
- "session_ids": [session_id] if session_id else [],
1708
+ "session_id": session_id or "",
1729
1709
  "transcript_path": "",
1730
1710
  "notification_message": "",
1731
1711
  "alias_announced": False
@@ -1758,12 +1738,19 @@ def update_instance_position(instance_name, update_fields):
1758
1738
  else:
1759
1739
  raise
1760
1740
 
1741
+ def set_status(instance_name: str, status: str, context: str = ''):
1742
+ """Set instance status event with timestamp"""
1743
+ update_instance_position(instance_name, {
1744
+ 'last_status': status,
1745
+ 'last_status_time': int(time.time()),
1746
+ 'last_status_context': context
1747
+ })
1748
+ log_hook_error(f"Set status for {instance_name} to {status} with context {context}") #TODO: change to 'log'?
1749
+
1761
1750
  def merge_instance_data(to_data, from_data):
1762
1751
  """Merge instance data from from_data into to_data."""
1763
- # Merge session_ids arrays with deduplication
1764
- to_sessions = to_data.get('session_ids', [])
1765
- from_sessions = from_data.get('session_ids', [])
1766
- to_data['session_ids'] = list(dict.fromkeys(to_sessions + from_sessions))
1752
+ # Use current session_id from source (overwrites previous)
1753
+ to_data['session_id'] = from_data.get('session_id', to_data.get('session_id', ''))
1767
1754
 
1768
1755
  # Update transient fields from source
1769
1756
  to_data['pid'] = os.getppid() # Always use current PID
@@ -1775,14 +1762,16 @@ def merge_instance_data(to_data, from_data):
1775
1762
  # Update directory to most recent
1776
1763
  to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
1777
1764
 
1778
- # Update last activity timestamps to most recent
1779
- to_data['last_tool'] = max(to_data.get('last_tool', 0), from_data.get('last_tool', 0))
1780
- to_data['last_tool_name'] = from_data.get('last_tool_name', to_data.get('last_tool_name', 'unknown'))
1765
+ # Update heartbeat timestamp to most recent
1781
1766
  to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
1782
- to_data['last_permission_request'] = max(
1783
- to_data.get('last_permission_request', 0),
1784
- from_data.get('last_permission_request', 0)
1785
- )
1767
+
1768
+ # Merge new status fields - take most recent status event
1769
+ from_time = from_data.get('last_status_time', 0)
1770
+ to_time = to_data.get('last_status_time', 0)
1771
+ if from_time > to_time:
1772
+ to_data['last_status'] = from_data.get('last_status', '')
1773
+ to_data['last_status_time'] = from_time
1774
+ to_data['last_status_context'] = from_data.get('last_status_context', '')
1786
1775
 
1787
1776
  # Preserve background mode if set
1788
1777
  to_data['background'] = to_data.get('background') or from_data.get('background')
@@ -1814,11 +1803,15 @@ def merge_instance_immediately(from_name, to_name):
1814
1803
  from_data = load_instance_position(from_name)
1815
1804
  to_data = load_instance_position(to_name)
1816
1805
 
1817
- # Check if target is active
1818
- if to_data.get('pid'):
1819
- if is_process_alive(to_data['pid']):
1820
- return f"Cannot recover {to_name}: instance is active"
1821
- # Process is dead, safe to merge
1806
+ # Check if target has recent activity (time-based check instead of PID)
1807
+ now = time.time()
1808
+ last_activity = max(
1809
+ to_data.get('last_stop', 0),
1810
+ to_data.get('last_status_time', 0)
1811
+ )
1812
+ time_since_activity = now - last_activity
1813
+ if time_since_activity < MERGE_ACTIVITY_THRESHOLD:
1814
+ return f"Cannot recover {to_name}: instance is active (activity {int(time_since_activity)}s ago)"
1822
1815
 
1823
1816
  # Merge data using helper
1824
1817
  to_data = merge_instance_data(to_data, from_data)
@@ -1830,12 +1823,12 @@ def merge_instance_immediately(from_name, to_name):
1830
1823
  # Cleanup source file only after successful save
1831
1824
  try:
1832
1825
  hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
1833
- except:
1826
+ except (FileNotFoundError, PermissionError, OSError):
1834
1827
  pass # Non-critical if cleanup fails
1835
1828
 
1836
- return f"[SUCCESS] ✓ Recovered: {from_name} → {to_name}"
1829
+ return f"[SUCCESS] ✓ Recovered alias: {to_name}"
1837
1830
  except Exception:
1838
- return f"Failed to merge {from_name} into {to_name}"
1831
+ return f"Failed to recover alias: {to_name}"
1839
1832
 
1840
1833
 
1841
1834
  # ==================== Command Functions ====================
@@ -1847,9 +1840,8 @@ def show_main_screen_header():
1847
1840
  log_file = hcom_path(LOG_FILE)
1848
1841
  all_messages = []
1849
1842
  if log_file.exists():
1850
- all_messages = parse_log_messages(log_file)
1851
- # message_count = len(all_messages)
1852
-
1843
+ all_messages = parse_log_messages(log_file).messages
1844
+
1853
1845
  print(f"{BOLD}HCOM{RESET} LOGS")
1854
1846
  print(f"{DIM}{'─'*40}{RESET}\n")
1855
1847
 
@@ -1898,13 +1890,15 @@ Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md"
1898
1890
 
1899
1891
  === ADDITIONAL INFO ===
1900
1892
 
1901
- CONCEPT: HCOM creates multi-agent collaboration by launching multiple Claude Code
1902
- instances in separate terminals that share a group chat.
1893
+ CONCEPT: HCOM launches Claude Code instances in new terminal windows.
1894
+ They communicate with each other via a shared conversation.
1895
+ You communicate with them via hcom automation commands.
1903
1896
 
1904
1897
  KEY UNDERSTANDING:
1905
1898
  • Single conversation - All instances share ~/.hcom/hcom.log
1906
- CLI usage - Use 'hcom send' for messaging. Internal instances know to use 'echo HCOM_SEND:'
1907
- hcom open is directory-specific - always cd to project directory first
1899
+ Messaging - Use 'hcom send "message"' from CLI to send messages to instances
1900
+ Instances receive messages via hooks automatically and send with 'eval $HCOM send "message"'
1901
+ • hcom open is directory-specific - always cd to project directory first
1908
1902
  • hcom watch --wait outputs existing logs, then waits for the next message, prints it, and exits.
1909
1903
  Times out after [seconds]
1910
1904
  • Named agents are custom system prompts created by users/claude code.
@@ -1912,7 +1906,7 @@ Times out after [seconds]
1912
1906
 
1913
1907
  LAUNCH PATTERNS:
1914
1908
  hcom open 2 reviewer # 2 generic + 1 reviewer agent
1915
- hcom open reviewer reviewer # 2 separate reviewer instances
1909
+ hcom open reviewer reviewer # 2 separate reviewer instances
1916
1910
  hcom open --prefix api 2 # Team naming: api-hova7, api-kolec
1917
1911
  hcom open --claude-args "--model sonnet" # Pass 'claude' CLI flags
1918
1912
  hcom open --background (or -p) then hcom kill # Detached background process
@@ -1926,10 +1920,12 @@ LAUNCH PATTERNS:
1926
1920
  (Unmatched @mentions broadcast to everyone)
1927
1921
 
1928
1922
  STATUS INDICATORS:
1929
- ◉ thinking, ▷ responding, executing - instance is working
1923
+ • ▶ active - instance is working (processing/executing)
1924
+ • ▷ delivered - instance just received a message
1930
1925
  • ◉ waiting - instance is waiting for new messages
1931
1926
  • ■ blocked - instance is blocked by permission request (needs user approval)
1932
1927
  • ○ inactive - instance is timed out, disconnected, etc
1928
+ • ○ unknown - no status information available
1933
1929
 
1934
1930
  CONFIG:
1935
1931
  Config file (persistent): ~/.hcom/config.json
@@ -1962,14 +1958,6 @@ def cmd_open(*args):
1962
1958
  # Parse arguments
1963
1959
  instances, prefix, claude_args, background = parse_open_args(list(args))
1964
1960
 
1965
- # Extract resume sessionId if present
1966
- resume_session_id = None
1967
- if claude_args:
1968
- for i, arg in enumerate(claude_args):
1969
- if arg in ['--resume', '-r'] and i + 1 < len(claude_args):
1970
- resume_session_id = claude_args[i + 1]
1971
- break
1972
-
1973
1961
  # Add -p flag and stream-json output for background mode if not already present
1974
1962
  if background and '-p' not in claude_args and '--print' not in claude_args:
1975
1963
  claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
@@ -2000,32 +1988,25 @@ def cmd_open(*args):
2000
1988
  # Build environment variables for Claude instances
2001
1989
  base_env = build_claude_env()
2002
1990
 
2003
- # Pass resume sessionId to hooks (only for first instance if multiple)
2004
- # This avoids conflicts when resuming with -n > 1
2005
- if resume_session_id:
2006
- if len(instances) > 1:
2007
- print(f"Warning: --resume with {len(instances)} instances will only resume the first instance", file=sys.stderr)
2008
- # Will be added to first instance env only
2009
-
2010
1991
  # Add prefix-specific hints if provided
2011
1992
  if prefix:
2012
1993
  base_env['HCOM_PREFIX'] = prefix
2013
- hint = f"To respond to {prefix} group: echo 'HCOM_SEND:@{prefix} message'"
1994
+ send_cmd = build_send_command()
1995
+ hint = f"To respond to {prefix} group: {send_cmd} '@{prefix} message'"
2014
1996
  base_env['HCOM_INSTANCE_HINTS'] = hint
2015
-
2016
- first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
1997
+ first_use = f"You're in the {prefix} group. Use {prefix} to message: {send_cmd} '@{prefix} message'"
2017
1998
  base_env['HCOM_FIRST_USE_TEXT'] = first_use
2018
1999
 
2019
2000
  launched = 0
2020
2001
  initial_prompt = get_config_value('initial_prompt', 'Say hi in chat')
2021
2002
 
2022
- for idx, instance_type in enumerate(instances):
2003
+ for _, instance_type in enumerate(instances):
2023
2004
  instance_env = base_env.copy()
2024
2005
 
2025
- # Add resume sessionId only to first instance when multiple instances
2026
- if resume_session_id and idx == 0:
2027
- instance_env['HCOM_RESUME_SESSION_ID'] = resume_session_id
2028
-
2006
+ # Set unique launch ID for sender detection in cmd_send()
2007
+ launch_id = f"{int(time.time())}_{random.randint(10000, 99999)}"
2008
+ instance_env['HCOM_LAUNCH_ID'] = launch_id
2009
+
2029
2010
  # Mark background instances via environment with log filename
2030
2011
  if background:
2031
2012
  # Generate unique log filename
@@ -2035,7 +2016,7 @@ def cmd_open(*args):
2035
2016
  # Build claude command
2036
2017
  if instance_type == 'generic':
2037
2018
  # Generic instance - no agent content
2038
- claude_cmd, temp_file = build_claude_command(
2019
+ claude_cmd, _ = build_claude_command(
2039
2020
  agent_content=None,
2040
2021
  claude_args=claude_args,
2041
2022
  initial_prompt=initial_prompt
@@ -2052,7 +2033,7 @@ def cmd_open(*args):
2052
2033
  # Use agent's model and tools if specified and not overridden in claude_args
2053
2034
  agent_model = agent_config.get('model')
2054
2035
  agent_tools = agent_config.get('tools')
2055
- claude_cmd, temp_file = build_claude_command(
2036
+ claude_cmd, _ = build_claude_command(
2056
2037
  agent_content=agent_content,
2057
2038
  claude_args=claude_args,
2058
2039
  initial_prompt=initial_prompt,
@@ -2168,7 +2149,7 @@ def cmd_watch(*args):
2168
2149
  # Atomic position capture BEFORE parsing (prevents race condition)
2169
2150
  if log_file.exists():
2170
2151
  last_pos = log_file.stat().st_size # Capture position first
2171
- messages = parse_log_messages(log_file)
2152
+ messages = parse_log_messages(log_file).messages
2172
2153
  else:
2173
2154
  last_pos = 0
2174
2155
  messages = []
@@ -2180,7 +2161,7 @@ def cmd_watch(*args):
2180
2161
 
2181
2162
  # Status to stderr, data to stdout
2182
2163
  if recent_messages:
2183
- print(f'---Showing last 5 seconds of messages---', file=sys.stderr)
2164
+ print(f'---Showing last 5 seconds of messages---', file=sys.stderr) #TODO: change this to recent messages and have logic like last 3 messages + all messages in last 5 seconds.
2184
2165
  for msg in recent_messages:
2185
2166
  print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
2186
2167
  print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
@@ -2196,7 +2177,7 @@ def cmd_watch(*args):
2196
2177
  new_messages = []
2197
2178
  if current_size > last_pos:
2198
2179
  # Capture new position BEFORE parsing (atomic)
2199
- new_messages = parse_log_messages(log_file, last_pos)
2180
+ new_messages = parse_log_messages(log_file, last_pos).messages
2200
2181
  if new_messages:
2201
2182
  for msg in new_messages:
2202
2183
  print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
@@ -2233,9 +2214,10 @@ def cmd_watch(*args):
2233
2214
  "status": status,
2234
2215
  "age": age.strip() if age else "",
2235
2216
  "directory": data.get("directory", "unknown"),
2236
- "session_ids": data.get("session_ids", []),
2237
- "last_tool": data.get("last_tool_name", "unknown"),
2238
- "last_tool_time": data.get("last_tool", 0),
2217
+ "session_id": data.get("session_id", ""),
2218
+ "last_status": data.get("last_status", ""),
2219
+ "last_status_time": data.get("last_status_time", 0),
2220
+ "last_status_context": data.get("last_status_context", ""),
2239
2221
  "pid": data.get("pid"),
2240
2222
  "background": bool(data.get("background"))
2241
2223
  }
@@ -2244,7 +2226,7 @@ def cmd_watch(*args):
2244
2226
  # Get recent messages
2245
2227
  messages = []
2246
2228
  if log_file.exists():
2247
- all_messages = parse_log_messages(log_file)
2229
+ all_messages = parse_log_messages(log_file).messages
2248
2230
  messages = all_messages[-5:] if all_messages else []
2249
2231
 
2250
2232
  # Output JSON
@@ -2264,6 +2246,7 @@ def cmd_watch(*args):
2264
2246
  print(" hcom watch --logs Show message history", file=sys.stderr)
2265
2247
  print(" hcom watch --status Show instance status", file=sys.stderr)
2266
2248
  print(" hcom watch --wait Wait for new messages", file=sys.stderr)
2249
+ print(" Full information: hcom --help")
2267
2250
 
2268
2251
  show_cli_hints()
2269
2252
 
@@ -2282,10 +2265,9 @@ def cmd_watch(*args):
2282
2265
 
2283
2266
  show_recent_messages(all_messages, limit=5)
2284
2267
  print(f"\n{DIM}· · · · watching for new messages · · · ·{RESET}")
2285
- print(f"{DIM}{'─' * 40}{RESET}")
2286
2268
 
2287
2269
  # Print newline to ensure status starts on its own line
2288
- # print()
2270
+ print()
2289
2271
 
2290
2272
  current_status = get_status_summary()
2291
2273
  update_status(f"{current_status}{status_suffix}")
@@ -2309,7 +2291,7 @@ def cmd_watch(*args):
2309
2291
  if log_file.exists():
2310
2292
  current_size = log_file.stat().st_size
2311
2293
  if current_size > last_pos:
2312
- new_messages = parse_log_messages(log_file, last_pos)
2294
+ new_messages = parse_log_messages(log_file, last_pos).messages
2313
2295
  # Use the last known status for consistency
2314
2296
  status_line_text = f"{last_status}{status_suffix}"
2315
2297
  for msg in new_messages:
@@ -2319,9 +2301,9 @@ def cmd_watch(*args):
2319
2301
  # Check for keyboard input
2320
2302
  ready_for_input = False
2321
2303
  if IS_WINDOWS:
2322
- import msvcrt
2323
- if msvcrt.kbhit():
2324
- msvcrt.getch()
2304
+ import msvcrt # type: ignore[import]
2305
+ if msvcrt.kbhit(): # type: ignore[attr-defined]
2306
+ msvcrt.getch() # type: ignore[attr-defined]
2325
2307
  ready_for_input = True
2326
2308
  else:
2327
2309
  if select.select([sys.stdin], [], [], 0.1)[0]:
@@ -2380,6 +2362,13 @@ def cmd_clear():
2380
2362
  if script_count > 0:
2381
2363
  print(f"Cleaned up {script_count} old script files")
2382
2364
 
2365
+ # Clean up old launch mapping files (older than 24 hours)
2366
+ if instances_dir.exists():
2367
+ cutoff_time = time.time() - (24 * 60 * 60) # 24 hours ago
2368
+ mapping_count = sum(1 for f in instances_dir.glob('.launch_map_*') if f.is_file() and f.stat().st_mtime < cutoff_time and f.unlink(missing_ok=True) is None)
2369
+ if mapping_count > 0:
2370
+ print(f"Cleaned up {mapping_count} old launch mapping files")
2371
+
2383
2372
  # Check if hcom files exist
2384
2373
  if not log_file.exists() and not instances_dir.exists():
2385
2374
  print("No hcom conversation to clear")
@@ -2410,11 +2399,15 @@ def cmd_clear():
2410
2399
  if has_instances:
2411
2400
  archive_instances = session_archive / INSTANCES_DIR
2412
2401
  archive_instances.mkdir(exist_ok=True)
2413
-
2402
+
2414
2403
  # Move json files only
2415
2404
  for f in instances_dir.glob('*.json'):
2416
2405
  f.rename(archive_instances / f.name)
2417
-
2406
+
2407
+ # Clean up orphaned mapping files (position files are archived)
2408
+ for f in instances_dir.glob('.launch_map_*'):
2409
+ f.unlink(missing_ok=True)
2410
+
2418
2411
  archived = True
2419
2412
  else:
2420
2413
  # Clean up empty files/dirs
@@ -2458,14 +2451,15 @@ def cleanup_directory_hooks(directory):
2458
2451
 
2459
2452
  hooks_found = False
2460
2453
 
2454
+ # Include PostToolUse for backward compatibility cleanup
2461
2455
  original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2462
- for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2463
-
2456
+ for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2457
+
2464
2458
  _remove_hcom_hooks_from_settings(settings)
2465
-
2459
+
2466
2460
  # Check if any were removed
2467
2461
  new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2468
- for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2462
+ for event in ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2469
2463
  if new_hook_count < original_hook_count:
2470
2464
  hooks_found = True
2471
2465
 
@@ -2529,6 +2523,7 @@ def cmd_kill(*args):
2529
2523
 
2530
2524
  # Mark instance as killed
2531
2525
  update_instance_position(target_name, {'pid': None})
2526
+ set_status(target_name, 'killed')
2532
2527
 
2533
2528
  if not instance_name:
2534
2529
  print(f"Killed {killed_count} instance(s)")
@@ -2597,35 +2592,58 @@ def cmd_send(message):
2597
2592
  # Check if hcom files exist
2598
2593
  log_file = hcom_path(LOG_FILE)
2599
2594
  instances_dir = hcom_path(INSTANCES_DIR)
2600
-
2595
+
2601
2596
  if not log_file.exists() and not instances_dir.exists():
2602
2597
  print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
2603
2598
  return 1
2604
-
2599
+
2605
2600
  # Validate message
2606
2601
  error = validate_message(message)
2607
2602
  if error:
2608
2603
  print(error, file=sys.stderr)
2609
2604
  return 1
2610
-
2605
+
2611
2606
  # Check for unmatched mentions (minimal warning)
2612
2607
  mentions = MENTION_PATTERN.findall(message)
2613
2608
  if mentions:
2614
2609
  try:
2615
2610
  positions = load_all_positions()
2616
2611
  all_instances = list(positions.keys())
2617
- unmatched = [m for m in mentions
2612
+ unmatched = [m for m in mentions
2618
2613
  if not any(name.lower().startswith(m.lower()) for name in all_instances)]
2619
2614
  if unmatched:
2620
2615
  print(f"Note: @{', @'.join(unmatched)} don't match any instances - broadcasting to all", file=sys.stderr)
2621
2616
  except Exception:
2622
2617
  pass # Don't fail on warning
2623
-
2624
- # Send message
2625
- sender_name = get_config_value('sender_name', 'bigboss')
2626
-
2618
+
2619
+ # Determine sender: lookup by launch_id, fallback to config
2620
+ sender_name = None
2621
+ launch_id = os.environ.get('HCOM_LAUNCH_ID')
2622
+ if launch_id:
2623
+ try:
2624
+ mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
2625
+ if mapping_file.exists():
2626
+ sender_name = mapping_file.read_text(encoding='utf-8').strip()
2627
+ except Exception:
2628
+ pass
2629
+
2630
+ if not sender_name:
2631
+ sender_name = get_config_value('sender_name', 'bigboss')
2632
+
2627
2633
  if send_message(sender_name, message):
2628
- print("Message sent", file=sys.stderr)
2634
+ # For instances: check for new messages and display immediately
2635
+ if launch_id: # Only for instances with HCOM_LAUNCH_ID
2636
+ messages = get_unread_messages(sender_name, update_position=True)
2637
+ if messages:
2638
+ max_msgs = get_config_value('max_messages_per_delivery', 50)
2639
+ messages_to_show = messages[:max_msgs]
2640
+ formatted = format_hook_messages(messages_to_show, sender_name)
2641
+ print(f"Message sent\n\n{formatted}", file=sys.stderr)
2642
+ else:
2643
+ print("Message sent", file=sys.stderr)
2644
+ else:
2645
+ # Bigboss: just confirm send
2646
+ print("Message sent", file=sys.stderr)
2629
2647
 
2630
2648
  # Show cli_hints if configured (non-interactive mode)
2631
2649
  if not is_interactive():
@@ -2636,7 +2654,50 @@ def cmd_send(message):
2636
2654
  print(format_error("Failed to send message"), file=sys.stderr)
2637
2655
  return 1
2638
2656
 
2639
- # ==================== Hook Functions ====================
2657
+ def cmd_resume_merge(alias: str) -> int:
2658
+ """Resume/merge current instance into an existing instance by alias.
2659
+
2660
+ INTERNAL COMMAND: Only called via '$HCOM send --resume alias' during implicit resume workflow.
2661
+ Not meant for direct CLI usage.
2662
+ """
2663
+ # Get current instance name via launch_id mapping (same mechanism as cmd_send)
2664
+ # The mapping is created by init_hook_context() when hooks run
2665
+ launch_id = os.environ.get('HCOM_LAUNCH_ID')
2666
+ if not launch_id:
2667
+ print(format_error("Not in HCOM instance context - no launch ID"), file=sys.stderr)
2668
+ return 1
2669
+
2670
+ instance_name = None
2671
+ try:
2672
+ mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}')
2673
+ if mapping_file.exists():
2674
+ instance_name = mapping_file.read_text(encoding='utf-8').strip()
2675
+ except Exception:
2676
+ pass
2677
+
2678
+ if not instance_name:
2679
+ print(format_error("Could not determine instance name"), file=sys.stderr)
2680
+ return 1
2681
+
2682
+ # Sanitize alias: only allow alphanumeric, dash, underscore
2683
+ # This prevents path traversal attacks (e.g., ../../etc, /etc, etc.)
2684
+ if not re.match(r'^[A-Za-z0-9\-_]+$', alias):
2685
+ print(format_error("Invalid alias format. Use alphanumeric, dash, or underscore only"), file=sys.stderr)
2686
+ return 1
2687
+
2688
+ # Attempt to merge current instance into target alias
2689
+ status = merge_instance_immediately(instance_name, alias)
2690
+
2691
+ # Handle results
2692
+ if not status:
2693
+ # Empty status means names matched (from_name == to_name)
2694
+ status = f"[SUCCESS] ✓ Already using alias {alias}"
2695
+
2696
+ # Print status and return
2697
+ print(status, file=sys.stderr)
2698
+ return 0 if status.startswith('[SUCCESS]') else 1
2699
+
2700
+ # ==================== Hook Helpers ====================
2640
2701
 
2641
2702
  def format_hook_messages(messages, instance_name):
2642
2703
  """Format messages for hook feedback"""
@@ -2645,13 +2706,7 @@ def format_hook_messages(messages, instance_name):
2645
2706
  reason = f"[new message] {msg['from']} → {instance_name}: {msg['message']}"
2646
2707
  else:
2647
2708
  parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
2648
- reason = f"[{len(messages)} new messages] | " + " | ".join(parts)
2649
-
2650
- # Check alias announcement
2651
- instance_data = load_instance_position(instance_name)
2652
- if not instance_data.get('alias_announced', False) and not instance_name.endswith('claude'):
2653
- 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)>"
2654
- update_instance_position(instance_name, {'alias_announced': True})
2709
+ reason = f"[{len(messages)} new messages] | {' | '.join(parts)}"
2655
2710
 
2656
2711
  # Only append instance_hints to messages (first_use_text is handled separately)
2657
2712
  instance_hints = get_config_value('instance_hints', '')
@@ -2660,149 +2715,76 @@ def format_hook_messages(messages, instance_name):
2660
2715
 
2661
2716
  return reason
2662
2717
 
2663
- def get_pending_tools(transcript_path, max_lines=100):
2664
- """Parse transcript to find tool_use IDs without matching tool_results.
2665
- Returns count of pending tools."""
2666
- if not transcript_path or not os.path.exists(transcript_path):
2667
- return 0
2668
-
2669
- tool_uses = set()
2670
- tool_results = set()
2671
-
2672
- try:
2673
- # Read last N lines efficiently
2674
- with open(transcript_path, 'rb') as f:
2675
- # Seek to end and read backwards
2676
- f.seek(0, 2) # Go to end
2677
- file_size = f.tell()
2678
- read_size = min(file_size, max_lines * 500) # Assume ~500 bytes per line
2679
- f.seek(max(0, file_size - read_size))
2680
- recent_content = f.read().decode('utf-8', errors='ignore')
2681
-
2682
- # Parse line by line (handle both Unix \n and Windows \r\n)
2683
- for line in recent_content.splitlines():
2684
- if not line.strip():
2685
- continue
2686
- try:
2687
- data = json.loads(line)
2688
-
2689
- # Check for tool_use blocks in assistant messages
2690
- if data.get('type') == 'assistant':
2691
- content = data.get('message', {}).get('content', [])
2692
- if isinstance(content, list):
2693
- for item in content:
2694
- if isinstance(item, dict) and item.get('type') == 'tool_use':
2695
- tool_id = item.get('id')
2696
- if tool_id:
2697
- tool_uses.add(tool_id)
2698
-
2699
- # Check for tool_results in user messages
2700
- elif data.get('type') == 'user':
2701
- content = data.get('message', {}).get('content', [])
2702
- if isinstance(content, list):
2703
- for item in content:
2704
- if isinstance(item, dict) and item.get('type') == 'tool_result':
2705
- tool_id = item.get('tool_use_id')
2706
- if tool_id:
2707
- tool_results.add(tool_id)
2708
- except Exception as e:
2709
- continue
2710
-
2711
- # Return count of pending tools
2712
- pending = tool_uses - tool_results
2713
- return len(pending)
2714
- except Exception as e:
2715
- return 0 # On any error, assume no pending tools
2716
-
2717
2718
  # ==================== Hook Handlers ====================
2718
2719
 
2719
- def init_hook_context(hook_data):
2720
+ def init_hook_context(hook_data, hook_type=None):
2720
2721
  """Initialize instance context - shared by post/stop/notify hooks"""
2722
+ import time
2723
+
2721
2724
  session_id = hook_data.get('session_id', '')
2722
2725
  transcript_path = hook_data.get('transcript_path', '')
2723
2726
  prefix = os.environ.get('HCOM_PREFIX')
2724
2727
 
2725
- # Check if this is a resume operation
2726
- resume_session_id = os.environ.get('HCOM_RESUME_SESSION_ID')
2727
2728
  instances_dir = hcom_path(INSTANCES_DIR)
2728
2729
  instance_name = None
2729
2730
  merged_state = None
2730
2731
 
2731
- # First, try to find existing instance by resume sessionId
2732
- if resume_session_id and instances_dir.exists():
2733
- for instance_file in instances_dir.glob("*.json"):
2734
- try:
2735
- data = load_instance_position(instance_file.stem)
2736
- # Check if resume_session_id matches any in the session_ids array
2737
- old_session_ids = data.get('session_ids', [])
2738
- if resume_session_id in old_session_ids:
2739
- # Found the instance! Keep the same name
2740
- instance_name = instance_file.stem
2741
- merged_state = data
2742
- # Append new session_id to array, update transcript_path to current
2743
- if session_id and session_id not in old_session_ids:
2744
- merged_state.setdefault('session_ids', old_session_ids).append(session_id)
2745
- if transcript_path:
2746
- merged_state['transcript_path'] = transcript_path
2747
- break
2748
- except:
2749
- continue
2750
-
2751
- # Check if current session exists in any instance's session_ids array
2752
- # This maintains identity after implicit HCOM_RESUME
2732
+ # Check if current session_id matches any existing instance
2733
+ # This maintains identity after resume/merge operations
2753
2734
  if not instance_name and session_id and instances_dir.exists():
2754
2735
  for instance_file in instances_dir.glob("*.json"):
2755
2736
  try:
2756
2737
  data = load_instance_position(instance_file.stem)
2757
- if session_id in data.get('session_ids', []):
2738
+ if session_id == data.get('session_id'):
2758
2739
  instance_name = instance_file.stem
2759
2740
  merged_state = data
2741
+ log_hook_error(f'DEBUG: Session_id {session_id[:8]} matched {instance_file.stem}, reusing that name')
2760
2742
  break
2761
- except:
2743
+ except (json.JSONDecodeError, OSError, KeyError):
2762
2744
  continue
2763
2745
 
2764
2746
  # If not found or not resuming, generate new name from session_id
2765
2747
  if not instance_name:
2766
2748
  instance_name = get_display_name(session_id, prefix)
2749
+ # DEBUG: Log name generation
2750
+ log_hook_error(f'DEBUG: Generated instance_name={instance_name} from session_id={session_id[:8] if session_id else "None"}')
2767
2751
 
2768
- # PID deduplication: Clean up any stale instance files with same PID
2769
- # Always run to clean up temp instances even after implicit resume
2770
- parent_pid = os.getppid()
2771
- if instances_dir.exists():
2772
- for instance_file in instances_dir.glob("*.json"):
2773
- if instance_file.stem != instance_name: # Skip current instance
2774
- try:
2775
- data = load_instance_position(instance_file.stem)
2776
- if data.get('pid') == parent_pid:
2777
- # Found duplicate with same PID - merge and delete
2778
- if not merged_state:
2779
- merged_state = data
2780
- else:
2781
- # Merge useful fields from duplicate
2782
- merged_state = merge_instance_data(merged_state, data)
2783
- instance_file.unlink() # Delete the duplicate file
2784
- # Don't break - could have multiple duplicates with same PID
2785
- except:
2786
- continue
2752
+ # Save launch_id instance_name mapping for cmd_send()
2753
+ launch_id = os.environ.get('HCOM_LAUNCH_ID')
2754
+ if launch_id:
2755
+ try:
2756
+ mapping_file = hcom_path(INSTANCES_DIR, f'.launch_map_{launch_id}', ensure_parent=True)
2757
+ mapping_file.write_text(instance_name, encoding='utf-8')
2758
+ log_hook_error(f'DEBUG: FINAL - Wrote launch_map_{launch_id} → {instance_name} (session_id={session_id[:8] if session_id else "None"})')
2759
+ except Exception:
2760
+ pass # Non-critical
2787
2761
 
2788
2762
  # Save migrated data if we have it
2789
2763
  if merged_state:
2790
2764
  save_instance_position(instance_name, merged_state)
2791
2765
 
2792
- initialize_instance_in_position_file(instance_name, session_id)
2793
- existing_data = load_instance_position(instance_name)
2766
+ # Check if instance is brand new or pre-existing (before creation (WWJD))
2767
+ instance_file = hcom_path(INSTANCES_DIR, f'{instance_name}.json')
2768
+ is_new_instance = not instance_file.exists()
2794
2769
 
2795
- # Prepare updates - use array for session_ids, single field for transcript_path
2796
- updates = {
2770
+ # Skip instance creation for unmatched SessionStart resumes (prevents orphans)
2771
+ # Instance will be created in UserPromptSubmit with correct session_id
2772
+ should_create_instance = not (
2773
+ hook_type == 'sessionstart' and
2774
+ hook_data.get('source', 'startup') == 'resume' and not merged_state
2775
+ )
2776
+ if should_create_instance:
2777
+ initialize_instance_in_position_file(instance_name, session_id)
2778
+ existing_data = load_instance_position(instance_name) if should_create_instance else {}
2779
+
2780
+ # Prepare updates
2781
+ updates: dict[str, Any] = {
2797
2782
  'directory': str(Path.cwd()),
2798
2783
  }
2799
2784
 
2800
- # Update session_ids array if we have a new session_id
2785
+ # Update session_id (overwrites previous)
2801
2786
  if session_id:
2802
- current_session_ids = existing_data.get('session_ids', [])
2803
- if session_id not in current_session_ids:
2804
- current_session_ids.append(session_id)
2805
- updates['session_ids'] = current_session_ids
2787
+ updates['session_id'] = session_id
2806
2788
 
2807
2789
  # Update transcript_path to current
2808
2790
  if transcript_path:
@@ -2817,276 +2799,205 @@ def init_hook_context(hook_data):
2817
2799
  updates['background'] = True
2818
2800
  updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
2819
2801
 
2820
- return instance_name, updates, existing_data
2821
-
2822
- def extract_hcom_command(command, prefix='HCOM_SEND'):
2823
- """Extract command payload with quote stripping"""
2824
- marker = f'{prefix}:'
2825
- if marker not in command:
2826
- return None
2827
-
2828
- parts = command.split(marker, 1)
2829
- if len(parts) <= 1:
2830
- return None
2831
-
2832
- payload = parts[1].strip()
2833
-
2834
- # Complex quote stripping logic (preserves exact behavior)
2835
- if len(payload) >= 2 and \
2836
- ((payload[0] == '"' and payload[-1] == '"') or \
2837
- (payload[0] == "'" and payload[-1] == "'")):
2838
- payload = payload[1:-1]
2839
- elif payload and payload[-1] in '"\'':
2840
- payload = payload[:-1]
2841
-
2842
- return payload if payload else None
2843
-
2844
- def _sanitize_alias(alias):
2845
- """Sanitize extracted alias: strip quotes/backticks, stop at first invalid char/whitespace."""
2846
- alias = alias.strip()
2847
- # Strip wrapping quotes/backticks iteratively
2848
- for _ in range(3):
2849
- if len(alias) >= 2 and alias[0] == alias[-1] and alias[0] in ['"', "'", '`']:
2850
- alias = alias[1:-1].strip()
2851
- elif alias and alias[-1] in ['"', "'", '`']:
2852
- alias = alias[:-1].strip()
2853
- else:
2854
- break
2855
- # Stop at first whitespace or invalid char
2856
- alias = re.split(r'[^A-Za-z0-9\-_]', alias)[0]
2857
- return alias
2858
-
2859
- def extract_resume_alias(command):
2860
- """Extract resume alias safely.
2861
- Priority:
2862
- 1) HCOM_SEND payload that starts with RESUME:alias
2863
- 2) Bare HCOM_RESUME:alias (only when not embedded in HCOM_SEND payload)
2864
- """
2865
- # 1) Prefer explicit HCOM_SEND payload
2866
- payload = extract_hcom_command(command)
2867
- if payload:
2868
- cand = payload.strip()
2869
- if cand.startswith('RESUME:'):
2870
- alias_raw = cand.split(':', 1)[1].strip()
2871
- alias = _sanitize_alias(alias_raw)
2872
- return alias or None
2873
- # If payload contains text like "HCOM_RESUME:alias" but not at start,
2874
- # ignore to prevent alias hijack from normal messages
2875
-
2876
- # 2) Fallback: bare HCOM_RESUME when not using HCOM_SEND
2877
- alias_raw = extract_hcom_command(command, 'HCOM_RESUME')
2878
- if alias_raw:
2879
- alias = _sanitize_alias(alias_raw)
2880
- return alias or None
2881
- return None
2882
-
2883
- def compute_decision_for_visibility(transcript_path):
2884
- """Compute hook decision based on pending tools to prevent API 400 errors."""
2885
- pending_tools = get_pending_tools(transcript_path)
2886
- decision = None if pending_tools > 0 else HOOK_DECISION_BLOCK
2887
-
2888
- return decision
2802
+ # Return flags indicating resume state
2803
+ is_resume_match = merged_state is not None
2804
+ return instance_name, updates, existing_data, is_resume_match, is_new_instance
2889
2805
 
2890
- def emit_resume_feedback(status, instance_name, transcript_path):
2891
- """Emit formatted resume feedback with appropriate visibility."""
2892
- # Build formatted feedback based on success/failure
2893
- if status.startswith("[SUCCESS]"):
2894
- reason = f"[{status}]{HCOM_FORMAT_INSTRUCTIONS}"
2895
- else:
2896
- reason = f"[⚠️ {status} - your alias is: {instance_name}]{HCOM_FORMAT_INSTRUCTIONS}"
2806
+ def handle_pretooluse(hook_data, instance_name, updates):
2807
+ """Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
2808
+ tool_name = hook_data.get('tool_name', '')
2897
2809
 
2898
- # Compute decision based on pending tools
2899
- decision = compute_decision_for_visibility(transcript_path)
2810
+ # Non-HCOM_SEND tools: record status (they'll run without permission check)
2811
+ set_status(instance_name, 'tool_pending', tool_name)
2900
2812
 
2901
- # Emit response
2902
- emit_hook_response(reason, decision=decision)
2813
+ import time
2903
2814
 
2904
- def handle_pretooluse(hook_data):
2905
- """Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
2906
- # Check if this is an HCOM_SEND command that needs auto-approval
2907
- tool_name = hook_data.get('tool_name', '')
2815
+ # Handle HCOM commands in Bash
2908
2816
  if tool_name == 'Bash':
2909
2817
  command = hook_data.get('tool_input', {}).get('command', '')
2910
- if 'HCOM_SEND:' in command or extract_resume_alias(command):
2911
- # Check if other tools are pending - prevent API 400 errors
2912
- transcript_path = hook_data.get('transcript_path', '')
2913
- # Subtract 1 because the current tool is already in transcript but not actually pending
2914
- pending_count = max(0, get_pending_tools(transcript_path) - 1)
2915
-
2916
- if pending_count > 0:
2917
- # Deny execution to prevent injecting content between tool_use/tool_result
2918
- output = {
2919
- "hookSpecificOutput": {
2920
- "hookEventName": "PreToolUse",
2921
- "permissionDecision": "deny",
2922
- "permissionDecisionReason": f"Waiting - {pending_count} tool(s) still executing. Try again in a moment."
2923
- }
2924
- }
2925
- else:
2926
- # Safe to proceed
2927
- output = {
2928
- "hookSpecificOutput": {
2929
- "hookEventName": "PreToolUse",
2930
- "permissionDecision": "allow",
2931
- "permissionDecisionReason": "HCOM_SEND command auto-approved"
2932
- }
2818
+ script_path = str(Path(__file__).resolve())
2819
+
2820
+ # === Auto-approve ALL '$HCOM send' commands (including --resume) ===
2821
+ # This includes:
2822
+ # - $HCOM send "message" (normal messaging between instances)
2823
+ # - $HCOM send --resume alias (resume/merge operation)
2824
+ if ('$HCOM send' in command or
2825
+ 'hcom send' in command or
2826
+ (script_path in command and ' send ' in command)):
2827
+ output = {
2828
+ "hookSpecificOutput": {
2829
+ "hookEventName": "PreToolUse",
2830
+ "permissionDecision": "allow",
2831
+ "permissionDecisionReason": "HCOM send command auto-approved"
2933
2832
  }
2833
+ }
2934
2834
  print(json.dumps(output, ensure_ascii=False))
2935
2835
  sys.exit(EXIT_SUCCESS)
2936
2836
 
2937
- def handle_posttooluse(hook_data, instance_name, updates):
2938
- """Handle PostToolUse hook - extract and deliver messages"""
2939
- updates['last_tool'] = int(time.time())
2940
- updates['last_tool_name'] = hook_data.get('tool_name', 'unknown')
2941
- update_instance_position(instance_name, updates)
2942
-
2943
- # Check for HCOM_SEND in Bash commands
2944
- sent_reason = None
2945
- if hook_data.get('tool_name') == 'Bash':
2946
- command = hook_data.get('tool_input', {}).get('command', '')
2947
2837
 
2948
- # Check for RESUME command first (safe extraction)
2949
- alias = extract_resume_alias(command)
2950
- if alias:
2951
- status = merge_instance_immediately(instance_name, alias)
2952
-
2953
- # If names match, find and merge any duplicate with same PID
2954
- if not status and instance_name == alias:
2955
- instances_dir = hcom_path(INSTANCES_DIR)
2956
- parent_pid = os.getppid()
2957
- if instances_dir.exists():
2958
- for instance_file in instances_dir.glob("*.json"):
2959
- if instance_file.stem != instance_name:
2960
- try:
2961
- data = load_instance_position(instance_file.stem)
2962
- if data.get('pid') == parent_pid:
2963
- # Found duplicate - merge it
2964
- status = merge_instance_immediately(instance_file.stem, instance_name)
2965
- if status:
2966
- status = f"[SUCCESS] ✓ Merged duplicate: {instance_file.stem} → {instance_name}"
2967
- break
2968
- except:
2969
- continue
2970
-
2971
- if not status:
2972
- status = f"[SUCCESS] ✓ Already using alias {alias}"
2973
- elif not status:
2974
- status = f"[WARNING] ⚠️ Merge failed: {instance_name} → {alias}"
2975
-
2976
- if status:
2977
- transcript_path = hook_data.get('transcript_path', '')
2978
- emit_resume_feedback(status, instance_name, transcript_path)
2979
- return # Don't process RESUME as regular message
2980
-
2981
- # Normal message handling
2982
- message = extract_hcom_command(command) # defaults to HCOM_SEND
2983
- if message:
2984
- error = validate_message(message)
2985
- if error:
2986
- emit_hook_response(f"❌ {error}")
2987
- send_message(instance_name, message)
2988
- sent_reason = "[✓ Sent]"
2989
-
2990
- # Check for pending tools in transcript
2991
- transcript_path = hook_data.get('transcript_path', '')
2992
- pending_count = get_pending_tools(transcript_path)
2993
-
2994
- # Build response if needed
2995
- response_reason = None
2996
-
2997
- # Only deliver messages when all tools are complete (pending_count == 0)
2998
- if pending_count == 0:
2999
- messages = get_new_messages(instance_name)
3000
- if messages:
3001
- messages = messages[:get_config_value('max_messages_per_delivery', 50)]
3002
- reason = format_hook_messages(messages, instance_name)
3003
- response_reason = f"{sent_reason} | {reason}" if sent_reason else reason
3004
- elif sent_reason:
3005
- response_reason = sent_reason
3006
- elif sent_reason:
3007
- # Tools still pending - acknowledge HCOM_SEND without disrupting tool batching
3008
- response_reason = sent_reason
3009
-
3010
- # Emit response with formatting if we have anything to say
3011
- if response_reason:
3012
- response_reason += HCOM_FORMAT_INSTRUCTIONS
3013
- # CRITICAL: decision=None when tools are pending to prevent API 400 errors
3014
- decision = compute_decision_for_visibility(transcript_path)
3015
- emit_hook_response(response_reason, decision=decision)
3016
-
3017
- def handle_stop(instance_name, updates):
3018
- """Handle Stop hook - poll for messages"""
3019
- updates['last_stop'] = time.time()
3020
- timeout = get_config_value('wait_timeout', 1800)
3021
- updates['wait_timeout'] = timeout
3022
-
3023
- # Try to update position, but continue on Windows file locking errors
2838
+ def safe_exit_with_status(instance_name, code=EXIT_SUCCESS):
2839
+ """Safely exit stop hook with proper status tracking"""
3024
2840
  try:
3025
- update_instance_position(instance_name, updates)
3026
- except Exception as e:
3027
- # Silently handle initial file locking error and continue
3028
- pass
2841
+ set_status(instance_name, 'stop_exit')
2842
+ except (OSError, PermissionError):
2843
+ pass # Silently handle any errors
2844
+ sys.exit(code)
3029
2845
 
3030
- parent_pid = os.getppid()
3031
- start_time = time.time()
2846
+ def handle_stop(hook_data, instance_name, updates):
2847
+ """Handle Stop hook - poll for messages and deliver"""
2848
+ import time as time_module
3032
2849
 
3033
- try:
3034
- loop_count = 0
3035
- while time.time() - start_time < timeout:
3036
- loop_count += 1
3037
- current_time = time.time()
2850
+ parent_pid = os.getppid()
2851
+ log_hook_error(f'stop:entering_stop_hook_now_pid_{os.getpid()}')
2852
+ log_hook_error(f'stop:entering_stop_hook_now_ppid_{parent_pid}')
3038
2853
 
3039
- # Unix/Mac: Check if orphaned (reparented to PID 1)
3040
- if not IS_WINDOWS and os.getppid() == 1:
3041
- sys.exit(EXIT_SUCCESS)
3042
2854
 
3043
- # All platforms: Check if parent is alive
3044
- parent_alive = is_parent_alive(parent_pid)
2855
+ try:
2856
+ entry_time = time_module.time()
2857
+ updates['last_stop'] = entry_time
2858
+ timeout = get_config_value('wait_timeout', 1800)
2859
+ updates['wait_timeout'] = timeout
2860
+ set_status(instance_name, 'waiting')
3045
2861
 
3046
- if not parent_alive:
3047
- sys.exit(EXIT_SUCCESS)
2862
+ try:
2863
+ update_instance_position(instance_name, updates)
2864
+ except Exception as e:
2865
+ log_hook_error(f'stop:update_instance_position({instance_name})', e)
3048
2866
 
3049
- # Check for pending tools before delivering messages
3050
- transcript_path = updates.get('transcript_path', '')
3051
- pending_count = get_pending_tools(transcript_path)
2867
+ start_time = time_module.time()
2868
+ log_hook_error(f'stop:start_time_pid_{os.getpid()}')
3052
2869
 
3053
- # Only deliver messages when no tools are pending
3054
- if pending_count == 0:
3055
- messages = get_new_messages(instance_name)
3056
- if messages:
2870
+ try:
2871
+ loop_count = 0
2872
+ # STEP 4: Actual polling loop - this IS the holding pattern
2873
+ while time_module.time() - start_time < timeout:
2874
+ if loop_count == 0:
2875
+ time_module.sleep(0.1) # Initial wait before first poll
2876
+ loop_count += 1
2877
+
2878
+ # Check if parent is alive
2879
+ if not IS_WINDOWS and os.getppid() == 1:
2880
+ log_hook_error(f'stop:parent_died_pid_{os.getpid()}')
2881
+ safe_exit_with_status(instance_name, EXIT_SUCCESS)
2882
+
2883
+ parent_alive = is_parent_alive(parent_pid)
2884
+ if not parent_alive:
2885
+ log_hook_error(f'stop:parent_not_alive_pid_{os.getpid()}')
2886
+ safe_exit_with_status(instance_name, EXIT_SUCCESS)
2887
+
2888
+ # Check if user input is pending - exit cleanly if so
2889
+ user_input_signal = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
2890
+ if user_input_signal.exists():
2891
+ log_hook_error(f'stop:user_input_pending_exiting_pid_{os.getpid()}')
2892
+ safe_exit_with_status(instance_name, EXIT_SUCCESS)
2893
+
2894
+ # Check for new messages and deliver
2895
+ if messages := get_unread_messages(instance_name, update_position=True):
3057
2896
  messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
3058
2897
  reason = format_hook_messages(messages_to_show, instance_name)
3059
- emit_hook_response(reason) # Normal visible delivery
2898
+ set_status(instance_name, 'message_delivered', messages_to_show[0]['from'])
3060
2899
 
3061
- # Update position to keep instance marked as alive
3062
- stop_update_time = time.time()
3063
- try:
3064
- update_instance_position(instance_name, {'last_stop': stop_update_time})
3065
- except Exception as e:
3066
- # Silently handle file locking exceptions on Windows and continue polling
3067
- pass
2900
+ log_hook_error(f'stop:delivering_message_pid_{os.getpid()}')
2901
+ output = {"decision": "block", "reason": reason}
2902
+ print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2903
+ sys.exit(EXIT_BLOCK)
3068
2904
 
3069
- time.sleep(STOP_HOOK_POLL_INTERVAL)
2905
+ # Update heartbeat
2906
+ try:
2907
+ update_instance_position(instance_name, {'last_stop': time_module.time()})
2908
+ # log_hook_error(f'hb_pid_{os.getpid()}')
2909
+ except Exception as e:
2910
+ log_hook_error(f'stop:heartbeat_update({instance_name})', e)
2911
+
2912
+ time_module.sleep(STOP_HOOK_POLL_INTERVAL)
2913
+
2914
+ except Exception as loop_e:
2915
+ # Log polling loop errors but continue to cleanup
2916
+ log_hook_error(f'stop:polling_loop({instance_name})', loop_e)
2917
+
2918
+ # Timeout reached
2919
+ set_status(instance_name, 'timeout')
3070
2920
 
3071
2921
  except Exception as e:
3072
- # Exit with code 0 on unexpected exceptions (fail safe)
3073
- sys.exit(EXIT_SUCCESS)
2922
+ # Log error and exit gracefully
2923
+ log_hook_error('handle_stop', e)
2924
+ safe_exit_with_status(instance_name, EXIT_SUCCESS)
3074
2925
 
3075
2926
  def handle_notify(hook_data, instance_name, updates):
3076
2927
  """Handle Notification hook - track permission requests"""
3077
- updates['last_permission_request'] = int(time.time())
3078
2928
  updates['notification_message'] = hook_data.get('message', '')
3079
2929
  update_instance_position(instance_name, updates)
2930
+ set_status(instance_name, 'blocked', hook_data.get('message', ''))
2931
+
2932
+ def handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance):
2933
+ """Handle UserPromptSubmit hook - track when user sends messages"""
2934
+ import time as time_module
2935
+
2936
+ # Update last user input timestamp
2937
+ updates['last_user_input'] = time_module.time()
2938
+ update_instance_position(instance_name, updates)
3080
2939
 
3081
- def handle_sessionstart(hook_data, instance_name, updates):
2940
+ # Signal any polling Stop hook to exit cleanly before user input processed
2941
+ signal_file = hcom_path(INSTANCES_DIR, f'.user_input_pending_{instance_name}')
2942
+ try:
2943
+ log_hook_error(f'userpromptsubmit:signal_file_touched_pid_{os.getpid()}')
2944
+ signal_file.touch()
2945
+ time_module.sleep(0.15) # Give Stop hook time to detect and exit
2946
+ log_hook_error(f'userpromptsubmit:signal_file_unlinked_pid_{os.getpid()}')
2947
+ signal_file.unlink()
2948
+ except (OSError, PermissionError) as e:
2949
+ log_hook_error(f'userpromptsubmit:signal_file_error', e)
2950
+
2951
+ send_cmd = build_send_command('your message')
2952
+ resume_cmd = send_cmd.replace("'your message'", "--resume your_old_alias")
2953
+ # Build message based on what happened
2954
+ msg = None
2955
+ if is_resume_match:
2956
+ msg = f"[Resumed with hcom chat alias: {instance_name}. Use {send_cmd} to send messages]"
2957
+ elif is_new_instance:
2958
+ # Unmatched resume - show critical recovery message
2959
+ msg = (
2960
+ f"[HCOM RESUME DETECTED - CRITICAL ACTION REQUIRED "
2961
+ f"You MUST recover your HCOM identity to maintain conversation context "
2962
+ f"Run: {resume_cmd} "
2963
+ f"This is REQUIRED for message history and position tracking]"
2964
+ )
2965
+ else:
2966
+ # Check if we need to announce alias (normal startup)
2967
+ instance_data = load_instance_position(instance_name)
2968
+ alias_announced = instance_data.get('alias_announced', False)
2969
+ if not alias_announced:
2970
+ msg = f"[Your hcom chat alias is {instance_name}. You can at-mention others in hcom chat by their alias to DM them. To send a message use: {send_cmd}]"
2971
+ update_instance_position(instance_name, {'alias_announced': True})
2972
+
2973
+ if msg:
2974
+ output = {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": msg}}
2975
+ print(json.dumps(output))
2976
+
2977
+ def handle_sessionstart(hook_data, instance_name, updates, is_resume_match):
3082
2978
  """Handle SessionStart hook - deliver welcome/resume message"""
3083
2979
  source = hook_data.get('source', 'startup')
3084
2980
 
2981
+ log_hook_error(f'sessionstart:is_resume_match_{is_resume_match}')
2982
+ log_hook_error(f'sessionstart:instance_name_{instance_name}')
2983
+ log_hook_error(f'sessionstart:source_{source}')
2984
+ log_hook_error(f'sessionstart:updates_{updates}')
2985
+ log_hook_error(f'sessionstart:hook_data_{hook_data}')
2986
+
3085
2987
  # Reset alias_announced flag so alias shows again on resume/clear/compact
3086
2988
  updates['alias_announced'] = False
3087
2989
 
3088
- # Always show base help text
3089
- help_text = "[Welcome! HCOM chat active. Send messages: echo 'HCOM_SEND:your message']"
2990
+ # Only update instance position if file exists (startup or matched resume)
2991
+ # For unmatched resumes, skip - UserPromptSubmit will create the file with correct session_id
2992
+ if source == 'startup' or is_resume_match:
2993
+ update_instance_position(instance_name, updates)
2994
+ set_status(instance_name, 'session_start')
2995
+
2996
+ log_hook_error(f'sessionstart:instance_name_after_update_{instance_name}')
2997
+
2998
+ # Build send command using helper
2999
+ send_cmd = build_send_command('your message')
3000
+ help_text = f"[Welcome! HCOM chat active. Send messages: {send_cmd}]"
3090
3001
 
3091
3002
  # Add subagent type if this is a named agent
3092
3003
  subagent_type = os.environ.get('HCOM_SUBAGENT_TYPE')
@@ -3099,11 +3010,10 @@ def handle_sessionstart(hook_data, instance_name, updates):
3099
3010
  if first_use_text:
3100
3011
  help_text += f" [{first_use_text}]"
3101
3012
  elif source == 'resume':
3102
- if not os.environ.get('HCOM_RESUME_SESSION_ID'):
3103
- # Implicit resume - prompt for alias recovery
3104
- help_text += f" [⚠️ Resume detected - temp: {instance_name}. If you had a previous HCOM alias, run: echo \"HCOM_RESUME:your_alias\"]"
3013
+ if is_resume_match:
3014
+ help_text += f" [Resumed alias: {instance_name}]"
3105
3015
  else:
3106
- help_text += " [Resuming session - you should have the same hcom alias as before]"
3016
+ help_text += f" [Session resumed]"
3107
3017
 
3108
3018
  # Add instance hints to all messages
3109
3019
  instance_hints = get_config_value('instance_hints', '')
@@ -3119,37 +3029,34 @@ def handle_sessionstart(hook_data, instance_name, updates):
3119
3029
  }
3120
3030
  print(json.dumps(output))
3121
3031
 
3122
- # Update instance position
3123
- update_instance_position(instance_name, updates)
3124
-
3125
- def handle_hook(hook_type):
3032
+ def handle_hook(hook_type: str) -> None:
3126
3033
  """Unified hook handler for all HCOM hooks"""
3127
3034
  if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
3128
3035
  sys.exit(EXIT_SUCCESS)
3129
3036
 
3130
- try:
3131
- hook_data = json.load(sys.stdin)
3037
+ hook_data = json.load(sys.stdin)
3038
+ log_hook_error(f'handle_hook:hook_data_{hook_data}')
3132
3039
 
3133
- # Route to specific handler with only needed parameters
3134
- if hook_type == 'pre':
3135
- # PreToolUse only needs hook_data
3136
- handle_pretooluse(hook_data)
3137
- else:
3138
- # Other hooks need context initialization
3139
- instance_name, updates, _ = init_hook_context(hook_data)
3140
-
3141
- if hook_type == 'post':
3142
- handle_posttooluse(hook_data, instance_name, updates)
3143
- elif hook_type == 'stop':
3144
- # Stop hook doesn't use hook_data
3145
- handle_stop(instance_name, updates)
3146
- elif hook_type == 'notify':
3147
- handle_notify(hook_data, instance_name, updates)
3148
- elif hook_type == 'sessionstart':
3149
- handle_sessionstart(hook_data, instance_name, updates)
3040
+ # DEBUG: Log which hook is being called with which session_id
3041
+ session_id_short = hook_data.get('session_id', 'none')[:8] if hook_data.get('session_id') else 'none'
3042
+ log_hook_error(f'DEBUG: Hook {hook_type} called with session_id={session_id_short}')
3043
+
3044
+ instance_name, updates, _, is_resume_match, is_new_instance = init_hook_context(hook_data, hook_type)
3045
+
3046
+ match hook_type:
3047
+ case 'pre':
3048
+ handle_pretooluse(hook_data, instance_name, updates)
3049
+ case 'stop':
3050
+ handle_stop(hook_data, instance_name, updates)
3051
+ case 'notify':
3052
+ handle_notify(hook_data, instance_name, updates)
3053
+ case 'userpromptsubmit':
3054
+ handle_userpromptsubmit(hook_data, instance_name, updates, is_resume_match, is_new_instance)
3055
+ case 'sessionstart':
3056
+ handle_sessionstart(hook_data, instance_name, updates, is_resume_match)
3057
+
3058
+ log_hook_error(f'handle_hook:instance_name_{instance_name}')
3150
3059
 
3151
- except Exception:
3152
- pass
3153
3060
 
3154
3061
  sys.exit(EXIT_SUCCESS)
3155
3062
 
@@ -3166,34 +3073,41 @@ def main(argv=None):
3166
3073
 
3167
3074
  cmd = argv[1]
3168
3075
 
3169
- # Main commands
3170
- if cmd == 'help' or cmd == '--help':
3171
- return cmd_help()
3172
- elif cmd == 'open':
3173
- return cmd_open(*argv[2:])
3174
- elif cmd == 'watch':
3175
- return cmd_watch(*argv[2:])
3176
- elif cmd == 'clear':
3177
- return cmd_clear()
3178
- elif cmd == 'cleanup':
3179
- return cmd_cleanup(*argv[2:])
3180
- elif cmd == 'send':
3181
- if len(argv) < 3:
3182
- print(format_error("Message required"), file=sys.stderr)
3076
+ match cmd:
3077
+ case 'help' | '--help':
3078
+ return cmd_help()
3079
+ case 'open':
3080
+ return cmd_open(*argv[2:])
3081
+ case 'watch':
3082
+ return cmd_watch(*argv[2:])
3083
+ case 'clear':
3084
+ return cmd_clear()
3085
+ case 'cleanup':
3086
+ return cmd_cleanup(*argv[2:])
3087
+ case 'send':
3088
+ if len(argv) < 3:
3089
+ print(format_error("Message required"), file=sys.stderr)
3090
+ return 1
3091
+
3092
+ # HIDDEN COMMAND: --resume is only used internally by instances during resume workflow
3093
+ # Not meant for regular CLI usage. Primary usage:
3094
+ # - From instances: $HCOM send "message" (instances send messages to each other)
3095
+ # - From CLI: hcom send "message" (user/claude orchestrator sends to instances)
3096
+ if argv[2] == '--resume':
3097
+ if len(argv) < 4:
3098
+ print(format_error("Alias required for --resume"), file=sys.stderr)
3099
+ return 1
3100
+ return cmd_resume_merge(argv[3])
3101
+
3102
+ return cmd_send(argv[2])
3103
+ case 'kill':
3104
+ return cmd_kill(*argv[2:])
3105
+ case 'stop' | 'notify' | 'pre' | 'sessionstart' | 'userpromptsubmit':
3106
+ handle_hook(cmd)
3107
+ return 0
3108
+ case _:
3109
+ print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
3183
3110
  return 1
3184
- return cmd_send(argv[2])
3185
- elif cmd == 'kill':
3186
- return cmd_kill(*argv[2:])
3187
-
3188
- # Hook commands
3189
- elif cmd in ['post', 'stop', 'notify', 'pre', 'sessionstart']:
3190
- handle_hook(cmd)
3191
- return 0
3192
-
3193
- # Unknown command
3194
- else:
3195
- print(format_error(f"Unknown command: {cmd}", "Run 'hcom help' for available commands"), file=sys.stderr)
3196
- return 1
3197
3111
 
3198
3112
  if __name__ == '__main__':
3199
3113
  sys.exit(main())