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

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

Potentially problematic release.


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

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