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

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

Potentially problematic release.


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

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