hcom 0.2.0__py3-none-any.whl → 0.2.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,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- hcom - Claude Hook Comms
4
- Lightweight CLI tool for real-time communication between Claude Code subagents using hooks
3
+ hcom 0.2.1
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
@@ -24,6 +24,16 @@ from datetime import datetime, timedelta
24
24
 
25
25
  IS_WINDOWS = sys.platform == 'win32'
26
26
 
27
+ def is_wsl():
28
+ """Detect if running in WSL (Windows Subsystem for Linux)"""
29
+ if platform.system() != 'Linux':
30
+ return False
31
+ try:
32
+ with open('/proc/version', 'r') as f:
33
+ return 'microsoft' in f.read().lower()
34
+ except:
35
+ return False
36
+
27
37
  HCOM_ACTIVE_ENV = 'HCOM_ACTIVE'
28
38
  HCOM_ACTIVE_VALUE = '1'
29
39
 
@@ -33,16 +43,55 @@ EXIT_BLOCK = 2
33
43
 
34
44
  HOOK_DECISION_BLOCK = 'block'
35
45
 
46
+ ERROR_ACCESS_DENIED = 5 # Windows - Process exists but no permission
47
+ ERROR_INVALID_PARAMETER = 87 # Windows - Invalid PID or parameters
48
+ ERROR_ALREADY_EXISTS = 183 # Windows - For file/mutex creation, not process checks
49
+
50
+ # Windows API constants
51
+ DETACHED_PROCESS = 0x00000008 # CreateProcess flag for no console window
52
+ CREATE_NO_WINDOW = 0x08000000 # Prevent console window creation
53
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 # Vista+ minimal access rights
54
+ 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?!
55
+
56
+ # Timing constants
57
+ FILE_RETRY_DELAY = 0.01 # 10ms delay for file lock retries
58
+ STOP_HOOK_POLL_INTERVAL = 0.05 # 50ms between stop hook polls
59
+ KILL_CHECK_INTERVAL = 0.1 # 100ms between process termination checks
60
+
61
+ # Windows kernel32 cache
62
+ _windows_kernel32_cache = None
63
+
64
+ def get_windows_kernel32():
65
+ """Get cached Windows kernel32 with function signatures configured.
66
+ This eliminates repeated initialization in hot code paths (e.g., stop hook polling).
67
+ """
68
+ global _windows_kernel32_cache
69
+ if _windows_kernel32_cache is None and IS_WINDOWS:
70
+ import ctypes
71
+ import ctypes.wintypes
72
+ kernel32 = ctypes.windll.kernel32
73
+
74
+ # Set proper ctypes function signatures to avoid ERROR_INVALID_PARAMETER
75
+ kernel32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
76
+ kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
77
+ kernel32.GetLastError.argtypes = []
78
+ kernel32.GetLastError.restype = ctypes.wintypes.DWORD
79
+ kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
80
+ kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
81
+ kernel32.GetExitCodeProcess.argtypes = [ctypes.wintypes.HANDLE, ctypes.POINTER(ctypes.wintypes.DWORD)]
82
+ kernel32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL
83
+
84
+ _windows_kernel32_cache = kernel32
85
+ return _windows_kernel32_cache
86
+
36
87
  MENTION_PATTERN = re.compile(r'(?<![a-zA-Z0-9._-])@(\w+)')
37
88
  TIMESTAMP_SPLIT_PATTERN = re.compile(r'\n(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+\|)')
38
89
 
39
90
  RESET = "\033[0m"
40
91
  DIM = "\033[2m"
41
92
  BOLD = "\033[1m"
42
- FG_BLUE = "\033[34m"
43
93
  FG_GREEN = "\033[32m"
44
94
  FG_CYAN = "\033[36m"
45
- FG_RED = "\033[31m"
46
95
  FG_WHITE = "\033[37m"
47
96
  FG_BLACK = "\033[30m"
48
97
  BG_BLUE = "\033[44m"
@@ -60,6 +109,23 @@ STATUS_MAP = {
60
109
  "inactive": (BG_RED, "○")
61
110
  }
62
111
 
112
+ # ==================== Windows/WSL Console Unicode ====================
113
+ import io
114
+
115
+ # Apply UTF-8 encoding for Windows and WSL
116
+ if IS_WINDOWS or is_wsl():
117
+ try:
118
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
119
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
120
+ except:
121
+ pass # Fallback if stream redirection fails
122
+
123
+ # ==================== Error Handling Strategy ====================
124
+ # Hooks: Must never raise exceptions (breaks hcom). Functions return True/False.
125
+ # CLI: Can raise exceptions for user feedback. Check return values.
126
+ # Critical I/O: atomic_write, save_instance_position, merge_instance_immediately
127
+ # Pattern: Try/except/return False in hooks, raise in CLI operations.
128
+
63
129
  # ==================== Configuration ====================
64
130
 
65
131
  DEFAULT_CONFIG = {
@@ -69,12 +135,13 @@ DEFAULT_CONFIG = {
69
135
  "sender_name": "bigboss",
70
136
  "sender_emoji": "🐳",
71
137
  "cli_hints": "",
72
- "wait_timeout": 1800,
73
- "max_message_size": 4096,
138
+ "wait_timeout": 1800, # 30mins
139
+ "max_message_size": 1048576, # 1MB
74
140
  "max_messages_per_delivery": 50,
75
141
  "first_use_text": "Essential, concise messages only, say hi in hcom chat now",
76
142
  "instance_hints": "",
77
- "env_overrides": {}
143
+ "env_overrides": {},
144
+ "auto_watch": True # Auto-launch watch dashboard after open
78
145
  }
79
146
 
80
147
  _config = None
@@ -90,74 +157,170 @@ HOOK_SETTINGS = {
90
157
  'cli_hints': 'HCOM_CLI_HINTS',
91
158
  'terminal_mode': 'HCOM_TERMINAL_MODE',
92
159
  'terminal_command': 'HCOM_TERMINAL_COMMAND',
93
- 'initial_prompt': 'HCOM_INITIAL_PROMPT'
160
+ 'initial_prompt': 'HCOM_INITIAL_PROMPT',
161
+ 'auto_watch': 'HCOM_AUTO_WATCH'
94
162
  }
95
163
 
96
- # ==================== File System Utilities ====================
164
+ # Path constants
165
+ LOG_FILE = "hcom.log"
166
+ INSTANCES_DIR = "instances"
167
+ LOGS_DIR = "logs"
168
+ CONFIG_FILE = "config.json"
169
+ ARCHIVE_DIR = "archive"
97
170
 
98
- def get_hcom_dir():
99
- """Get the hcom directory in user's home"""
100
- return Path.home() / ".hcom"
171
+ # ==================== File System Utilities ====================
101
172
 
102
- def ensure_hcom_dir():
103
- """Create the hcom directory if it doesn't exist"""
104
- hcom_dir = get_hcom_dir()
105
- hcom_dir.mkdir(exist_ok=True)
106
- return hcom_dir
173
+ def hcom_path(*parts, ensure_parent=False):
174
+ """Build path under ~/.hcom"""
175
+ path = Path.home() / ".hcom"
176
+ if parts:
177
+ path = path.joinpath(*parts)
178
+ if ensure_parent:
179
+ path.parent.mkdir(parents=True, exist_ok=True)
180
+ return path
107
181
 
108
182
  def atomic_write(filepath, content):
109
- """Write content to file atomically to prevent corruption"""
110
- filepath = Path(filepath)
111
- with tempfile.NamedTemporaryFile(mode='w', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
112
- tmp.write(content)
113
- tmp.flush()
114
- os.fsync(tmp.fileno())
115
-
116
- os.replace(tmp.name, filepath)
183
+ """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."""
184
+ filepath = Path(filepath) if not isinstance(filepath, Path) else filepath
185
+
186
+ for attempt in range(3):
187
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', delete=False, dir=filepath.parent, suffix='.tmp') as tmp:
188
+ tmp.write(content)
189
+ tmp.flush()
190
+ os.fsync(tmp.fileno())
191
+
192
+ try:
193
+ os.replace(tmp.name, filepath)
194
+ return True
195
+ except PermissionError:
196
+ if IS_WINDOWS and attempt < 2:
197
+ time.sleep(FILE_RETRY_DELAY)
198
+ continue
199
+ else:
200
+ try: # Clean up temp file on final failure
201
+ os.unlink(tmp.name)
202
+ except:
203
+ pass
204
+ return False
205
+ except Exception:
206
+ try: # Clean up temp file on any other error
207
+ os.unlink(tmp.name)
208
+ except:
209
+ pass
210
+ return False
211
+
212
+ def read_file_with_retry(filepath, read_func, default=None, max_retries=3):
213
+ """Read file with retry logic for Windows file locking"""
214
+ if not Path(filepath).exists():
215
+ return default
216
+
217
+ for attempt in range(max_retries):
218
+ try:
219
+ with open(filepath, 'r', encoding='utf-8') as f:
220
+ return read_func(f)
221
+ except PermissionError as e:
222
+ # Only retry on Windows (file locking issue)
223
+ if IS_WINDOWS and attempt < max_retries - 1:
224
+ time.sleep(FILE_RETRY_DELAY)
225
+ else:
226
+ # Re-raise on Unix or after max retries on Windows
227
+ if not IS_WINDOWS:
228
+ raise # Unix permission errors are real issues
229
+ break # Windows: return default after retries
230
+ except (json.JSONDecodeError, FileNotFoundError, IOError):
231
+ break # Don't retry on other errors
232
+
233
+ return default
117
234
 
118
235
  def get_instance_file(instance_name):
119
236
  """Get path to instance's position file"""
120
- return get_hcom_dir() / "instances" / f"{instance_name}.json"
237
+ return hcom_path(INSTANCES_DIR, f"{instance_name}.json")
238
+
239
+ def migrate_instance_data_v020(data, instance_name):
240
+ """One-time migration from v0.2.0 format (remove in v0.3.0)"""
241
+ needs_save = False
242
+
243
+ # Convert single session_id to session_ids array
244
+ if 'session_ids' not in data and 'session_id' in data and data['session_id']:
245
+ data['session_ids'] = [data['session_id']]
246
+ needs_save = True
247
+
248
+ # Remove conversation_uuid - no longer used anywhere
249
+ if 'conversation_uuid' in data:
250
+ del data['conversation_uuid']
251
+ needs_save = True
252
+
253
+ if needs_save:
254
+ save_instance_position(instance_name, data)
255
+
256
+ return data
121
257
 
122
258
  def load_instance_position(instance_name):
123
259
  """Load position data for a single instance"""
124
260
  instance_file = get_instance_file(instance_name)
125
- if instance_file.exists():
126
- try:
127
- with open(instance_file, 'r') as f:
128
- return json.load(f)
129
- except (json.JSONDecodeError, FileNotFoundError):
130
- pass
131
- return {}
261
+
262
+ data = read_file_with_retry(
263
+ instance_file,
264
+ lambda f: json.load(f),
265
+ default={}
266
+ )
267
+
268
+ # Apply migration if needed
269
+ if data:
270
+ data = migrate_instance_data_v020(data, instance_name)
271
+
272
+ return data
132
273
 
133
274
  def save_instance_position(instance_name, data):
134
- """Save position data for a single instance - no locking needed"""
135
- instance_file = get_instance_file(instance_name)
136
- instance_file.parent.mkdir(exist_ok=True)
137
- atomic_write(instance_file, json.dumps(data, indent=2))
275
+ """Save position data for a single instance. Returns True on success, False on failure."""
276
+ try:
277
+ instance_file = hcom_path(INSTANCES_DIR, f"{instance_name}.json", ensure_parent=True)
278
+ return atomic_write(instance_file, json.dumps(data, indent=2))
279
+ except:
280
+ return False
138
281
 
139
282
  def load_all_positions():
140
283
  """Load positions from all instance files"""
141
- instances_dir = get_hcom_dir() / "instances"
284
+ instances_dir = hcom_path(INSTANCES_DIR)
142
285
  if not instances_dir.exists():
143
286
  return {}
144
-
287
+
145
288
  positions = {}
146
289
  for instance_file in instances_dir.glob("*.json"):
147
- try:
148
- instance_name = instance_file.stem
149
- with open(instance_file, 'r') as f:
150
- positions[instance_name] = json.load(f)
151
- except (json.JSONDecodeError, FileNotFoundError):
152
- continue
290
+ instance_name = instance_file.stem
291
+ data = read_file_with_retry(
292
+ instance_file,
293
+ lambda f: json.load(f),
294
+ default={}
295
+ )
296
+ if data:
297
+ positions[instance_name] = data
153
298
  return positions
154
299
 
155
300
  def clear_all_positions():
156
301
  """Clear all instance position files"""
157
- instances_dir = get_hcom_dir() / "instances"
302
+ instances_dir = hcom_path(INSTANCES_DIR)
158
303
  if instances_dir.exists():
159
- shutil.rmtree(instances_dir)
160
- instances_dir.mkdir(exist_ok=True)
304
+ for f in instances_dir.glob('*.json'):
305
+ f.unlink()
306
+ else:
307
+ instances_dir.mkdir(exist_ok=True)
308
+
309
+ # ==================== Hook Utilities ====================
310
+
311
+ HCOM_FORMAT_INSTRUCTIONS = """\n\nREQUIRED: Display HCOM 'reason' content using this format with markdown:
312
+ ═════════ HCOM ═════════
313
+ ***[✓ Sent] [new message] and/or [all other info in square brackets]***
314
+ **name → name:** *message content*
315
+ ════════════════════════
316
+ Always show exact full content of every single message without saying why you are displaying it. This format is mandatory for system functionality"""
317
+
318
+ def emit_hook_response(reason, decision=HOOK_DECISION_BLOCK, exit_code=EXIT_BLOCK):
319
+ """Emit standardized hook JSON response"""
320
+ output = {"decision": decision, "reason": reason} if decision else {"reason": reason}
321
+ print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
322
+ sys.exit(exit_code)
323
+
161
324
 
162
325
  # ==================== Configuration System ====================
163
326
 
@@ -170,28 +333,28 @@ def get_cached_config():
170
333
 
171
334
  def _load_config_from_file():
172
335
  """Actually load configuration from ~/.hcom/config.json"""
173
- ensure_hcom_dir()
174
- config_path = get_hcom_dir() / 'config.json'
175
-
176
- config = DEFAULT_CONFIG.copy()
177
- config['env_overrides'] = DEFAULT_CONFIG['env_overrides'].copy()
178
-
179
- if config_path.exists():
180
- try:
181
- with open(config_path, 'r') as f:
182
- user_config = json.load(f)
183
-
184
- for key, value in user_config.items():
185
- if key == 'env_overrides':
186
- config['env_overrides'].update(value)
187
- else:
188
- config[key] = value
189
-
190
- except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
191
- print(format_warning("Cannot read config file, using defaults"), file=sys.stderr)
192
- else:
193
- atomic_write(config_path, json.dumps(DEFAULT_CONFIG, indent=2))
194
-
336
+ import copy
337
+ config_path = hcom_path(CONFIG_FILE, ensure_parent=True)
338
+
339
+ config = copy.deepcopy(DEFAULT_CONFIG)
340
+
341
+ try:
342
+ user_config = read_file_with_retry(
343
+ config_path,
344
+ lambda f: json.load(f),
345
+ default=None
346
+ )
347
+ if user_config:
348
+ for key, value in user_config.items():
349
+ if key == 'env_overrides':
350
+ config['env_overrides'].update(value)
351
+ else:
352
+ config[key] = value
353
+ elif not config_path.exists():
354
+ atomic_write(config_path, json.dumps(DEFAULT_CONFIG, indent=2))
355
+ except (json.JSONDecodeError, UnicodeDecodeError, PermissionError):
356
+ print("Warning: Cannot read config file, using defaults", file=sys.stderr)
357
+
195
358
  return config
196
359
 
197
360
  def get_config_value(key, default=None):
@@ -209,9 +372,11 @@ def get_config_value(key, default=None):
209
372
  return int(env_value)
210
373
  except ValueError:
211
374
  pass
375
+ elif key == 'auto_watch': # Convert string to boolean
376
+ return env_value.lower() in ('true', '1', 'yes', 'on')
212
377
  else:
213
378
  return env_value
214
-
379
+
215
380
  config = get_cached_config()
216
381
  return config.get(key, default)
217
382
 
@@ -224,13 +389,18 @@ def get_hook_command():
224
389
  python_path = sys.executable
225
390
  script_path = os.path.abspath(__file__)
226
391
 
227
- if ' ' in python_path or ' ' in script_path:
228
- # Paths with spaces: use conditional check
229
- escaped_python = shlex.quote(python_path)
230
- escaped_script = shlex.quote(script_path)
392
+ if IS_WINDOWS:
393
+ # Windows cmd.exe syntax - no parentheses so arguments append correctly
394
+ if ' ' in python_path or ' ' in script_path:
395
+ return f'IF "%HCOM_ACTIVE%"=="1" "{python_path}" "{script_path}"', {}
396
+ return f'IF "%HCOM_ACTIVE%"=="1" {python_path} {script_path}', {}
397
+ elif ' ' in python_path or ' ' in script_path:
398
+ # Unix with spaces: use conditional check
399
+ escaped_python = shell_quote(python_path)
400
+ escaped_script = shell_quote(script_path)
231
401
  return f'[ "${{HCOM_ACTIVE}}" = "1" ] && {escaped_python} {escaped_script} || true', {}
232
402
  else:
233
- # Clean paths: use environment variable
403
+ # Unix clean paths: use environment variable
234
404
  return '${HCOM:-true}', {}
235
405
 
236
406
  def build_claude_env():
@@ -266,32 +436,21 @@ def validate_message(message):
266
436
  """Validate message size and content"""
267
437
  if not message or not message.strip():
268
438
  return format_error("Message required")
269
-
270
- max_size = get_config_value('max_message_size', 4096)
439
+
440
+ # Reject control characters (except \n, \r, \t)
441
+ if re.search(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\u0080-\u009F]', message):
442
+ return format_error("Message contains control characters")
443
+
444
+ max_size = get_config_value('max_message_size', 1048576)
271
445
  if len(message) > max_size:
272
446
  return format_error(f"Message too large (max {max_size} chars)")
273
-
274
- return None
275
-
276
- def require_args(min_count, usage_msg, extra_msg=""):
277
- """Check argument count and exit with usage if insufficient"""
278
- if len(sys.argv) < min_count:
279
- print(f"Usage: {usage_msg}")
280
- if extra_msg:
281
- print(extra_msg)
282
- sys.exit(1)
283
447
 
284
- def load_positions(pos_file=None):
285
- """Load all positions - redirects to load_all_positions()"""
286
- # pos_file parameter kept for compatibility but ignored
287
- return load_all_positions()
448
+ return None
288
449
 
289
450
  def send_message(from_instance, message):
290
451
  """Send a message to the log"""
291
452
  try:
292
- ensure_hcom_dir()
293
- log_file = get_hcom_dir() / "hcom.log"
294
- pos_file = get_hcom_dir() / "hcom.json"
453
+ log_file = hcom_path(LOG_FILE, ensure_parent=True)
295
454
 
296
455
  escaped_message = message.replace('|', '\\|')
297
456
  escaped_from = from_instance.replace('|', '\\|')
@@ -299,7 +458,7 @@ def send_message(from_instance, message):
299
458
  timestamp = datetime.now().isoformat()
300
459
  line = f"{timestamp}|{escaped_from}|{escaped_message}\n"
301
460
 
302
- with open(log_file, 'a') as f:
461
+ with open(log_file, 'a', encoding='utf-8') as f:
303
462
  f.write(line)
304
463
  f.flush()
305
464
 
@@ -436,10 +595,16 @@ def resolve_agent(name):
436
595
 
437
596
  Returns tuple: (content after stripping YAML frontmatter, config dict)
438
597
  """
439
- for base_path in [Path('.'), Path.home()]:
598
+ for base_path in [Path.cwd(), Path.home()]:
440
599
  agent_path = base_path / '.claude/agents' / f'{name}.md'
441
600
  if agent_path.exists():
442
- content = agent_path.read_text()
601
+ content = read_file_with_retry(
602
+ agent_path,
603
+ lambda f: f.read(),
604
+ default=None
605
+ )
606
+ if content is None:
607
+ continue # Skip to next base_path if read failed
443
608
  config = extract_agent_config(content)
444
609
  stripped = strip_frontmatter(content)
445
610
  if not stripped.strip():
@@ -452,28 +617,53 @@ def strip_frontmatter(content):
452
617
  """Strip YAML frontmatter from agent file"""
453
618
  if content.startswith('---'):
454
619
  # Find the closing --- on its own line
455
- lines = content.split('\n')
620
+ lines = content.splitlines()
456
621
  for i, line in enumerate(lines[1:], 1):
457
622
  if line.strip() == '---':
458
623
  return '\n'.join(lines[i+1:]).strip()
459
624
  return content
460
625
 
461
- def get_display_name(transcript_path, prefix=None):
462
- """Get display name for instance"""
626
+ def get_display_name(session_id, prefix=None):
627
+ """Get display name for instance using session_id"""
463
628
  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']
464
- dir_name = Path.cwd().name
465
- dir_chars = (dir_name + 'xx')[:2].lower() # Pad short names to ensure 2 chars
466
-
467
- conversation_uuid = get_conversation_uuid(transcript_path)
468
-
469
- if conversation_uuid:
470
- hash_val = sum(ord(c) for c in conversation_uuid)
471
- uuid_char = conversation_uuid[0]
472
- base_name = f"{dir_chars}{syls[hash_val % len(syls)]}{uuid_char}"
629
+ # Phonetic letters (5 per syllable, matches syls order)
630
+ phonetic = "nrlstnrlstnrlstnrlstnrlstnrlstnmlstnmlstnrlmtnrlmtnrlmsnrlmsnrlstnrlstnrlmtnrlmtnrlaynrlaynrlaynrlayaanxrtanxrtdtraxntdaxntraxnrdaynrlaynrlasnrlst"
631
+
632
+ dir_char = (Path.cwd().name + 'x')[0].lower()
633
+
634
+ # Use session_id directly instead of extracting UUID from transcript
635
+ if session_id:
636
+ hash_val = sum(ord(c) for c in session_id)
637
+ syl_idx = hash_val % len(syls)
638
+ syllable = syls[syl_idx]
639
+
640
+ letters = phonetic[syl_idx * 5:(syl_idx + 1) * 5]
641
+ letter_hash = sum(ord(c) for c in session_id[1:]) if len(session_id) > 1 else hash_val
642
+ letter = letters[letter_hash % 5]
643
+
644
+ # Session IDs are UUIDs like "374acbe2-978b-4882-9c0b-641890f066e1"
645
+ hex_char = session_id[0] if session_id else 'x'
646
+ base_name = f"{dir_char}{syllable}{letter}{hex_char}"
647
+
648
+ # Collision detection: if taken by another PID, use more session_id chars
649
+ instance_file = hcom_path(INSTANCES_DIR, f"{base_name}.json")
650
+ if instance_file.exists():
651
+ try:
652
+ with open(instance_file, 'r', encoding='utf-8') as f:
653
+ data = json.load(f)
654
+ their_pid = data.get('pid')
655
+ our_pid = os.getppid()
656
+ # Only consider it a collision if they have a PID and it's different
657
+ if their_pid and their_pid != our_pid:
658
+ # Use first 4 chars of session_id for collision resolution
659
+ base_name = f"{dir_char}{session_id[0:4]}"
660
+ except:
661
+ pass
473
662
  else:
474
- pid_suffix = os.getppid() % 10000 # 4 digits max
475
- base_name = f"{dir_chars}{pid_suffix}claude"
476
-
663
+ # Fallback to PID-based naming if no session_id
664
+ pid_suffix = os.getppid() % 10000
665
+ base_name = f"{dir_char}{pid_suffix}claude"
666
+
477
667
  if prefix:
478
668
  return f"{prefix}-{base_name}"
479
669
  return base_name
@@ -498,18 +688,22 @@ def _remove_hcom_hooks_from_settings(settings):
498
688
  # - sh -c "[ ... ] && ... hcom ..."
499
689
  # - "/path with spaces/python" "/path with spaces/hcom.py" post/stop/notify
500
690
  # - '/path/to/python' '/path/to/hcom.py' post/stop/notify
691
+ # Note: Modern hooks use either ${HCOM:-true} (pattern 1) or the HCOM_ACTIVE conditional
692
+ # with full paths (pattern 2), both of which match all hook types including pre/sessionstart.
693
+ # The (post|stop|notify) patterns (3-6) are for older direct command formats that didn't
694
+ # include pre/sessionstart hooks.
501
695
  hcom_patterns = [
502
- r'\$\{?HCOM', # Environment variable (with or without braces)
503
- r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with HCOM_ACTIVE check
504
- r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command
505
- r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command
506
- r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote
507
- r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b(?=\s|$)', # Quoted path with hcom.py (more precise)
696
+ r'\$\{?HCOM', # Environment variable (${HCOM:-true}) - all hook types
697
+ r'\bHCOM_ACTIVE.*hcom\.py', # Conditional with full path - all hook types
698
+ r'\bhcom\s+(post|stop|notify)\b', # Direct hcom command (older format)
699
+ r'\buvx\s+hcom\s+(post|stop|notify)\b', # uvx hcom command (older format)
700
+ r'hcom\.py["\']?\s+(post|stop|notify)\b', # hcom.py with optional quote (older format)
701
+ r'["\'][^"\']*hcom\.py["\']?\s+(post|stop|notify)\b(?=\s|$)', # Quoted path (older format)
508
702
  r'sh\s+-c.*hcom', # Shell wrapper with hcom
509
703
  ]
510
704
  compiled_patterns = [re.compile(pattern) for pattern in hcom_patterns]
511
705
 
512
- for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
706
+ for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
513
707
  if event not in settings['hooks']:
514
708
  continue
515
709
 
@@ -547,20 +741,14 @@ def _remove_hcom_hooks_from_settings(settings):
547
741
 
548
742
 
549
743
  def build_env_string(env_vars, format_type="bash"):
550
- """Build environment variable string for different shells"""
744
+ """Build environment variable string for bash shells"""
551
745
  if format_type == "bash_export":
552
746
  # Properly escape values for bash
553
747
  return ' '.join(f'export {k}={shlex.quote(str(v))};' for k, v in env_vars.items())
554
- elif format_type == "powershell":
555
- # PowerShell environment variable syntax
556
- items = []
557
- for k, v in env_vars.items():
558
- escaped_value = str(v).replace('"', '`"')
559
- items.append(f'$env:{k}="{escaped_value}"')
560
- return ' ; '.join(items)
561
748
  else:
562
749
  return ' '.join(f'{k}={shlex.quote(str(v))}' for k, v in env_vars.items())
563
750
 
751
+
564
752
  def format_error(message, suggestion=None):
565
753
  """Format error message consistently"""
566
754
  base = f"Error: {message}"
@@ -568,38 +756,32 @@ def format_error(message, suggestion=None):
568
756
  base += f". {suggestion}"
569
757
  return base
570
758
 
571
- def format_warning(message):
572
- """Format warning message consistently"""
573
- return f"Warning: {message}"
759
+
760
+ def has_claude_arg(claude_args, arg_names, arg_prefixes):
761
+ """Check if argument already exists in claude_args"""
762
+ return claude_args and any(
763
+ arg in arg_names or arg.startswith(arg_prefixes)
764
+ for arg in claude_args
765
+ )
574
766
 
575
767
  def build_claude_command(agent_content=None, claude_args=None, initial_prompt="Say hi in chat", model=None, tools=None):
576
768
  """Build Claude command with proper argument handling
577
-
769
+
578
770
  Returns tuple: (command_string, temp_file_path_or_none)
579
771
  For agent content, writes to temp file and uses cat to read it.
580
772
  """
581
773
  cmd_parts = ['claude']
582
774
  temp_file_path = None
583
-
775
+
584
776
  # Add model if specified and not already in claude_args
585
777
  if model:
586
- # Check if model already specified in args (more concise)
587
- has_model = claude_args and any(
588
- arg in ['--model', '-m'] or
589
- arg.startswith(('--model=', '-m='))
590
- for arg in claude_args
591
- )
592
- if not has_model:
778
+ if not has_claude_arg(claude_args, ['--model', '-m'], ('--model=', '-m=')):
593
779
  cmd_parts.extend(['--model', model])
594
-
780
+
595
781
  # Add allowed tools if specified and not already in claude_args
596
782
  if tools:
597
- has_tools = claude_args and any(
598
- arg in ['--allowedTools', '--allowed-tools'] or
599
- arg.startswith(('--allowedTools=', '--allowed-tools='))
600
- for arg in claude_args
601
- )
602
- if not has_tools:
783
+ if not has_claude_arg(claude_args, ['--allowedTools', '--allowed-tools'],
784
+ ('--allowedTools=', '--allowed-tools=')):
603
785
  cmd_parts.extend(['--allowedTools', tools])
604
786
 
605
787
  if claude_args:
@@ -607,7 +789,7 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
607
789
  cmd_parts.append(shlex.quote(arg))
608
790
 
609
791
  if agent_content:
610
- temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False,
792
+ temp_file = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False,
611
793
  prefix='hcom_agent_', dir=tempfile.gettempdir())
612
794
  temp_file.write(agent_content)
613
795
  temp_file.close()
@@ -619,18 +801,13 @@ def build_claude_command(agent_content=None, claude_args=None, initial_prompt="S
619
801
  flag = '--append-system-prompt'
620
802
 
621
803
  cmd_parts.append(flag)
622
- if sys.platform == 'win32':
623
- # PowerShell handles paths differently, quote with single quotes
624
- escaped_path = temp_file_path.replace("'", "''")
625
- cmd_parts.append(f"\"$(Get-Content '{escaped_path}' -Raw)\"")
626
- else:
627
- cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
804
+ cmd_parts.append(f'"$(cat {shlex.quote(temp_file_path)})"')
628
805
 
629
806
  if claude_args or agent_content:
630
807
  cmd_parts.append('--')
631
808
 
632
809
  # Quote initial prompt normally
633
- cmd_parts.append(shlex.quote(initial_prompt))
810
+ cmd_parts.append(shell_quote(initial_prompt))
634
811
 
635
812
  return ' '.join(cmd_parts), temp_file_path
636
813
 
@@ -644,34 +821,111 @@ def escape_for_platform(text, platform_type):
644
821
  .replace('\n', '\\n') # Escape newlines
645
822
  .replace('\r', '\\r') # Escape carriage returns
646
823
  .replace('\t', '\\t')) # Escape tabs
647
- elif platform_type == 'powershell':
648
- # PowerShell escaping - use backticks for special chars
649
- # Quote paths with spaces for PowerShell
650
- escaped = text.replace('`', '``').replace('"', '`"').replace('$', '`$')
651
- if ' ' in text and not (text.startswith('"') and text.endswith('"')):
652
- return f'"{escaped}"'
653
- return escaped
654
824
  else: # POSIX/bash
655
825
  return shlex.quote(text)
656
826
 
657
- def safe_command_substitution(template, **substitutions):
658
- """Safely substitute values into command templates with automatic quoting"""
659
- result = template
660
- for key, value in substitutions.items():
661
- placeholder = f'{{{key}}}'
662
- if placeholder in result:
663
- # Auto-quote substitutions unless already quoted
664
- if key == 'env':
665
- # env_str is already properly quoted
666
- quoted_value = str(value)
667
- else:
668
- quoted_value = shlex.quote(str(value))
669
- result = result.replace(placeholder, quoted_value)
670
- return result
827
+ def shell_quote(text):
828
+ """Cross-platform shell argument quoting
829
+
830
+ Note: On Windows with Git Bash, subprocess.Popen(shell=True) uses bash, not cmd.exe
831
+ """
832
+ # Always use shlex.quote for proper bash escaping
833
+ # Git Bash on Windows uses bash as the shell
834
+ return shlex.quote(text)
835
+
836
+ def create_bash_script(script_file, env, cwd, command_str, background=False):
837
+ """Create a bash script for terminal launch"""
838
+ try:
839
+ # Ensure parent directory exists
840
+ script_path = Path(script_file)
841
+ script_path.parent.mkdir(parents=True, exist_ok=True)
842
+ except (OSError, IOError) as e:
843
+ raise Exception(f"Cannot create script directory: {e}")
844
+
845
+ with open(script_file, 'w', encoding='utf-8') as f:
846
+ f.write('#!/bin/bash\n')
847
+ f.write('echo "Starting Claude Code..."\n')
848
+ f.write(build_env_string(env, "bash_export") + '\n')
849
+ if cwd:
850
+ f.write(f'cd {shlex.quote(cwd)}\n')
851
+
852
+ # On Windows, let bash resolve claude from PATH (Windows paths don't work in bash)
853
+ if platform.system() != 'Windows':
854
+ claude_path = shutil.which('claude')
855
+ if claude_path:
856
+ command_str = command_str.replace('claude ', f'{claude_path} ', 1)
857
+
858
+ f.write(f'{command_str}\n')
859
+
860
+ # Self-delete for normal mode (not background or agent)
861
+ if not background and 'hcom_agent_' not in command_str:
862
+ f.write(f'rm -f {shlex.quote(script_file)}\n')
863
+
864
+ # Make executable on Unix
865
+ if platform.system() != 'Windows':
866
+ os.chmod(script_file, 0o755)
867
+
868
+ def find_bash_on_windows():
869
+ """Find Git Bash on Windows, avoiding WSL's bash launcher"""
870
+ # Build prioritized list of bash candidates
871
+ candidates = []
872
+
873
+ # 1. Common Git Bash locations (highest priority)
874
+ for base in [os.environ.get('PROGRAMFILES', r'C:\Program Files'),
875
+ os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)')]:
876
+ if base:
877
+ candidates.extend([
878
+ os.path.join(base, 'Git', 'usr', 'bin', 'bash.exe'), # usr/bin is more common
879
+ os.path.join(base, 'Git', 'bin', 'bash.exe')
880
+ ])
881
+
882
+ # 2. Portable Git installation
883
+ local_appdata = os.environ.get('LOCALAPPDATA', '')
884
+ if local_appdata:
885
+ git_portable = os.path.join(local_appdata, 'Programs', 'Git')
886
+ candidates.extend([
887
+ os.path.join(git_portable, 'usr', 'bin', 'bash.exe'),
888
+ os.path.join(git_portable, 'bin', 'bash.exe')
889
+ ])
890
+
891
+ # 3. PATH bash (if not WSL's launcher)
892
+ path_bash = shutil.which('bash')
893
+ if path_bash and not path_bash.lower().endswith(r'system32\bash.exe'):
894
+ candidates.append(path_bash)
895
+
896
+ # 4. Hardcoded fallbacks (last resort)
897
+ candidates.extend([
898
+ r'C:\Program Files\Git\usr\bin\bash.exe',
899
+ r'C:\Program Files\Git\bin\bash.exe',
900
+ r'C:\Program Files (x86)\Git\usr\bin\bash.exe',
901
+ r'C:\Program Files (x86)\Git\bin\bash.exe'
902
+ ])
903
+
904
+ # Find first existing bash
905
+ for bash in candidates:
906
+ if bash and os.path.exists(bash):
907
+ return bash
908
+
909
+ return None
910
+
911
+ def schedule_file_cleanup(files, delay=5):
912
+ """Schedule cleanup of temporary files after delay"""
913
+ if not files:
914
+ return
915
+
916
+ def cleanup():
917
+ time.sleep(delay)
918
+ for file_path in files:
919
+ try:
920
+ os.unlink(file_path)
921
+ except (OSError, FileNotFoundError):
922
+ pass # File already deleted or inaccessible
923
+
924
+ thread = threading.Thread(target=cleanup, daemon=True)
925
+ thread.start()
671
926
 
672
927
  def launch_terminal(command, env, config=None, cwd=None, background=False):
673
928
  """Launch terminal with command
674
-
675
929
  Args:
676
930
  command: Either a string command or list of command parts
677
931
  env: Environment variables to set
@@ -679,83 +933,174 @@ def launch_terminal(command, env, config=None, cwd=None, background=False):
679
933
  cwd: Working directory
680
934
  background: Launch as background process
681
935
  """
936
+
682
937
  if config is None:
683
938
  config = get_cached_config()
684
-
939
+
685
940
  env_vars = os.environ.copy()
686
941
  env_vars.update(env)
687
-
942
+
688
943
  # Command should now always be a string from build_claude_command
689
944
  command_str = command
690
945
 
691
946
  # Background mode implementation
692
947
  if background:
693
948
  # Create log file for background instance
694
- logs_dir = get_hcom_dir() / 'logs'
695
- logs_dir.mkdir(exist_ok=True)
949
+ logs_dir = hcom_path(LOGS_DIR)
950
+ logs_dir.mkdir(parents=True, exist_ok=True)
696
951
  log_file = logs_dir / env['HCOM_BACKGROUND']
697
-
952
+
698
953
  # Launch detached process
699
954
  try:
700
- with open(log_file, 'w') as log_handle:
701
- process = subprocess.Popen(
702
- command_str,
703
- shell=True,
704
- env=env_vars,
705
- cwd=cwd,
706
- stdin=subprocess.DEVNULL,
707
- stdout=log_handle,
708
- stderr=subprocess.STDOUT,
709
- start_new_session=True # detach from terminal session
710
- )
955
+ with open(log_file, 'w', encoding='utf-8') as log_handle:
956
+ if IS_WINDOWS:
957
+ # Windows: Use bash script approach for proper $(cat ...) support
958
+ bash_exe = find_bash_on_windows()
959
+ if not bash_exe:
960
+ raise Exception("Git Bash not found")
961
+
962
+ # Create script file for background process
963
+ script_file = str(hcom_path('scripts',
964
+ f'background_{os.getpid()}_{random.randint(1000,9999)}.sh',
965
+ ensure_parent=True))
966
+
967
+ create_bash_script(script_file, env, cwd, command_str, background=True)
968
+
969
+ # Windows requires STARTUPINFO to hide console window
970
+ startupinfo = subprocess.STARTUPINFO()
971
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
972
+ startupinfo.wShowWindow = subprocess.SW_HIDE
973
+
974
+ # Use bash.exe directly with script, avoiding shell=True and cmd.exe
975
+ process = subprocess.Popen(
976
+ [bash_exe, script_file],
977
+ env=env_vars,
978
+ cwd=cwd,
979
+ stdin=subprocess.DEVNULL,
980
+ stdout=log_handle,
981
+ stderr=subprocess.STDOUT,
982
+ startupinfo=startupinfo,
983
+ creationflags=CREATE_NO_WINDOW # Belt and suspenders approach
984
+ )
985
+ else:
986
+ # Unix: use start_new_session for proper detachment
987
+ process = subprocess.Popen(
988
+ command_str,
989
+ shell=True,
990
+ env=env_vars,
991
+ cwd=cwd,
992
+ stdin=subprocess.DEVNULL,
993
+ stdout=log_handle,
994
+ stderr=subprocess.STDOUT,
995
+ start_new_session=True # detach from terminal session
996
+ )
711
997
  except OSError as e:
712
- print(f"Error: Failed to launch background instance: {e}", file=sys.stderr)
998
+ print(format_error(f"Failed to launch background instance: {e}"), file=sys.stderr)
713
999
  return None
714
1000
 
715
1001
  # Check for immediate failures
716
1002
  time.sleep(0.2)
717
1003
  if process.poll() is not None:
718
1004
  # Process already exited
719
- with open(log_file, 'r') as f:
720
- error_output = f.read()[:1000]
721
- print(f"Error: Background instance failed immediately", file=sys.stderr)
1005
+ error_output = read_file_with_retry(
1006
+ log_file,
1007
+ lambda f: f.read()[:1000],
1008
+ default=""
1009
+ )
1010
+ print(format_error("Background instance failed immediately"), file=sys.stderr)
722
1011
  if error_output:
723
1012
  print(f" Output: {error_output}", file=sys.stderr)
724
1013
  return None
725
1014
 
726
1015
  return str(log_file)
727
-
1016
+
728
1017
  terminal_mode = get_config_value('terminal_mode', 'new_window')
729
-
1018
+
730
1019
  if terminal_mode == 'show_commands':
731
1020
  env_str = build_env_string(env)
732
1021
  print(f"{env_str} {command_str}")
733
1022
  return True
734
-
1023
+
735
1024
  elif terminal_mode == 'same_terminal':
736
1025
  print(f"Launching Claude in current terminal...")
737
- result = subprocess.run(command_str, shell=True, env=env_vars, cwd=cwd)
1026
+ if IS_WINDOWS:
1027
+ # Windows: Use bash directly to support $(cat ...) syntax
1028
+ bash_exe = find_bash_on_windows()
1029
+ if not bash_exe:
1030
+ print(format_error("Git Bash not found"), file=sys.stderr)
1031
+ return False
1032
+ # Run with bash -c to execute in current terminal
1033
+ result = subprocess.run([bash_exe, '-c', command_str], env=env_vars, cwd=cwd)
1034
+ else:
1035
+ # Unix/Linux/Mac: shell=True works fine
1036
+ result = subprocess.run(command_str, shell=True, env=env_vars, cwd=cwd)
1037
+
738
1038
  return result.returncode == 0
739
1039
 
740
1040
  system = platform.system()
741
-
1041
+
742
1042
  custom_cmd = get_config_value('terminal_command')
1043
+
1044
+ # Check for macOS Terminal.app 1024 char TTY limit
1045
+ if not custom_cmd and system == 'Darwin':
1046
+ full_cmd = build_env_string(env, "bash_export") + command_str
1047
+ if cwd:
1048
+ full_cmd = f'cd {shlex.quote(cwd)}; {full_cmd}'
1049
+ if len(full_cmd) > 1000: # Switch before 1024 limit
1050
+ # Force script path by setting custom_cmd
1051
+ custom_cmd = 'osascript -e \'tell app "Terminal" to do script "{script}"\''
1052
+
1053
+ # Windows also needs script approach - treat it like custom terminal
1054
+ if system == 'Windows' and not custom_cmd:
1055
+ # Windows always uses script approach with bash
1056
+ bash_exe = find_bash_on_windows()
1057
+ if not bash_exe:
1058
+ raise Exception(format_error("Git Bash not found"))
1059
+ # Set up to use script approach below
1060
+ if shutil.which('wt'):
1061
+ # Windows Terminal available
1062
+ custom_cmd = f'wt {bash_exe} {{script}}'
1063
+ else:
1064
+ # Use cmd.exe with start command for visible window
1065
+ custom_cmd = f'cmd /c start "Claude Code" {bash_exe} {{script}}'
1066
+
743
1067
  if custom_cmd and custom_cmd != 'None' and custom_cmd != 'null':
744
- # Replace placeholders
745
- env_str = build_env_string(env)
746
- working_dir = cwd or os.getcwd()
747
-
748
- final_cmd = safe_command_substitution(
749
- custom_cmd,
750
- cmd=command_str,
751
- env=env_str, # Already quoted
752
- cwd=working_dir
753
- )
754
-
755
- result = subprocess.run(final_cmd, shell=True, capture_output=True)
756
- if result.returncode != 0:
757
- raise subprocess.CalledProcessError(result.returncode, final_cmd, result.stderr)
758
- return True
1068
+ # Check for {script} placeholder - the reliable way to handle complex commands
1069
+ if '{script}' in custom_cmd:
1070
+ # Create temp script file
1071
+ # Always use .sh extension for bash scripts
1072
+ script_ext = '.sh'
1073
+ # Use ~/.hcom/scripts/ instead of /tmp to avoid noexec issues
1074
+ script_file = str(hcom_path('scripts',
1075
+ f'launch_{os.getpid()}_{random.randint(1000,9999)}{script_ext}',
1076
+ ensure_parent=True))
1077
+
1078
+ # Create the bash script using helper
1079
+ create_bash_script(script_file, env, cwd, command_str, background)
1080
+
1081
+ # Replace {script} with the script path
1082
+ final_cmd = custom_cmd.replace('{script}', shlex.quote(script_file))
1083
+
1084
+ # Windows needs special flags
1085
+ if system == 'Windows':
1086
+ # Use Popen for non-blocking launch on Windows
1087
+ # Use shell=True for Windows to handle complex commands properly
1088
+ subprocess.Popen(final_cmd, shell=True)
1089
+ else:
1090
+ # TODO: Test if macOS/Linux will still work with Popen for parallel launches with custom terminals like windows
1091
+ result = subprocess.run(final_cmd, shell=True, capture_output=True)
1092
+
1093
+ # Schedule cleanup for all scripts (since we don't wait for completion)
1094
+ schedule_file_cleanup([script_file])
1095
+
1096
+ return True
1097
+
1098
+ # No {script} placeholder found
1099
+ else:
1100
+ print(format_error("Custom terminal command must use {script} placeholder",
1101
+ "Example: open -n -a kitty.app --args bash \"{script}\"'"),
1102
+ file=sys.stderr)
1103
+ return False
759
1104
 
760
1105
  if system == 'Darwin': # macOS
761
1106
  env_setup = build_env_string(env, "bash_export")
@@ -773,12 +1118,13 @@ def launch_terminal(command, env, config=None, cwd=None, background=False):
773
1118
  return True
774
1119
 
775
1120
  elif system == 'Linux':
1121
+ # Try Linux terminals first (works for both regular Linux and WSLg)
776
1122
  terminals = [
777
1123
  ('gnome-terminal', ['gnome-terminal', '--', 'bash', '-c']),
778
1124
  ('konsole', ['konsole', '-e', 'bash', '-c']),
779
1125
  ('xterm', ['xterm', '-e', 'bash', '-c'])
780
1126
  ]
781
-
1127
+
782
1128
  for term_name, term_cmd in terminals:
783
1129
  if shutil.which(term_name):
784
1130
  env_cmd = build_env_string(env)
@@ -789,27 +1135,30 @@ def launch_terminal(command, env, config=None, cwd=None, background=False):
789
1135
  full_cmd = f'{env_cmd} {command_str}; exec bash'
790
1136
  subprocess.run(term_cmd + [full_cmd])
791
1137
  return True
792
-
1138
+
1139
+ # No Linux terminals found - check if WSL and can use Windows Terminal as fallback
1140
+ if is_wsl() and shutil.which('cmd.exe'):
1141
+ # WSL fallback: Use Windows Terminal through cmd.exe
1142
+ script_file = str(hcom_path('scripts',
1143
+ f'wsl_launch_{os.getpid()}_{random.randint(1000,9999)}.sh',
1144
+ ensure_parent=True))
1145
+
1146
+ create_bash_script(script_file, env, cwd, command_str, background=False)
1147
+
1148
+ # Use Windows Terminal if available, otherwise cmd.exe with start
1149
+ if shutil.which('wt.exe'):
1150
+ subprocess.run(['cmd.exe', '/c', 'start', 'wt.exe', 'bash', script_file])
1151
+ else:
1152
+ subprocess.run(['cmd.exe', '/c', 'start', 'bash', script_file])
1153
+
1154
+ # Schedule cleanup
1155
+ schedule_file_cleanup([script_file], delay=5)
1156
+ return True
1157
+
793
1158
  raise Exception(format_error("No supported terminal emulator found", "Install gnome-terminal, konsole, or xterm"))
794
1159
 
795
- elif system == 'Windows':
796
- # Windows Terminal with PowerShell
797
- env_setup = build_env_string(env, "powershell")
798
- # Include cd command if cwd is specified
799
- if cwd:
800
- full_cmd = f'cd "{cwd}" ; {env_setup} ; {command_str}'
801
- else:
802
- full_cmd = f'{env_setup} ; {command_str}'
803
-
804
- try:
805
- # Try Windows Terminal with PowerShell
806
- subprocess.run(['wt', 'powershell', '-NoExit', '-Command', full_cmd])
807
- except FileNotFoundError:
808
- # Fallback to PowerShell directly
809
- subprocess.run(['powershell', '-NoExit', '-Command', full_cmd])
810
- return True
811
-
812
1160
  else:
1161
+ # Windows is now handled by the custom_cmd logic above
813
1162
  raise Exception(format_error(f"Unsupported platform: {system}", "Supported platforms: macOS, Linux, Windows"))
814
1163
 
815
1164
  def setup_hooks():
@@ -818,80 +1167,50 @@ def setup_hooks():
818
1167
  claude_dir.mkdir(exist_ok=True)
819
1168
 
820
1169
  settings_path = claude_dir / 'settings.local.json'
821
- settings = {}
822
-
823
- if settings_path.exists():
824
- try:
825
- with open(settings_path, 'r') as f:
826
- settings = json.load(f)
827
- except (json.JSONDecodeError, PermissionError) as e:
828
- raise Exception(format_error(f"Cannot read settings: {e}"))
1170
+ try:
1171
+ settings = read_file_with_retry(
1172
+ settings_path,
1173
+ lambda f: json.load(f),
1174
+ default={}
1175
+ )
1176
+ except (json.JSONDecodeError, PermissionError) as e:
1177
+ raise Exception(format_error(f"Cannot read settings: {e}"))
829
1178
 
830
1179
  if 'hooks' not in settings:
831
1180
  settings['hooks'] = {}
832
- if 'permissions' not in settings:
833
- settings['permissions'] = {}
834
- if 'allow' not in settings['permissions']:
835
- settings['permissions']['allow'] = []
836
-
1181
+
837
1182
  _remove_hcom_hooks_from_settings(settings)
838
-
839
- if 'hooks' not in settings:
840
- settings['hooks'] = {}
841
1183
 
842
1184
  # Get the hook command template
843
1185
  hook_cmd_base, _ = get_hook_command()
844
1186
 
845
- # Add PreToolUse hook for auto-approving HCOM_SEND commands
846
- if 'PreToolUse' not in settings['hooks']:
847
- settings['hooks']['PreToolUse'] = []
848
-
849
- settings['hooks']['PreToolUse'].append({
850
- 'matcher': 'Bash',
851
- 'hooks': [{
852
- 'type': 'command',
853
- 'command': f'{hook_cmd_base} pre'
854
- }]
855
- })
856
-
857
- # Add PostToolUse hook
858
- if 'PostToolUse' not in settings['hooks']:
859
- settings['hooks']['PostToolUse'] = []
860
-
861
- settings['hooks']['PostToolUse'].append({
862
- 'matcher': '.*',
863
- 'hooks': [{
864
- 'type': 'command',
865
- 'command': f'{hook_cmd_base} post'
866
- }]
867
- })
868
-
869
- # Add Stop hook
870
- if 'Stop' not in settings['hooks']:
871
- settings['hooks']['Stop'] = []
872
-
1187
+ # Get wait_timeout (needed for Stop hook)
873
1188
  wait_timeout = get_config_value('wait_timeout', 1800)
874
1189
 
875
- settings['hooks']['Stop'].append({
876
- 'matcher': '',
877
- 'hooks': [{
878
- 'type': 'command',
879
- 'command': f'{hook_cmd_base} stop',
880
- 'timeout': wait_timeout
881
- }]
882
- })
883
-
884
- # Add Notification hook
885
- if 'Notification' not in settings['hooks']:
886
- settings['hooks']['Notification'] = []
887
-
888
- settings['hooks']['Notification'].append({
889
- 'matcher': '',
890
- 'hooks': [{
891
- 'type': 'command',
892
- 'command': f'{hook_cmd_base} notify'
893
- }]
894
- })
1190
+ # Define all hooks
1191
+ hook_configs = [
1192
+ ('SessionStart', '', f'{hook_cmd_base} sessionstart', None),
1193
+ ('PreToolUse', 'Bash', f'{hook_cmd_base} pre', None),
1194
+ ('PostToolUse', '.*', f'{hook_cmd_base} post', None),
1195
+ ('Stop', '', f'{hook_cmd_base} stop', wait_timeout),
1196
+ ('Notification', '', f'{hook_cmd_base} notify', None),
1197
+ ]
1198
+
1199
+ for hook_type, matcher, command, timeout in hook_configs:
1200
+ if hook_type not in settings['hooks']:
1201
+ settings['hooks'][hook_type] = []
1202
+
1203
+ hook_dict = {
1204
+ 'matcher': matcher,
1205
+ 'hooks': [{
1206
+ 'type': 'command',
1207
+ 'command': command
1208
+ }]
1209
+ }
1210
+ if timeout is not None:
1211
+ hook_dict['hooks'][0]['timeout'] = timeout
1212
+
1213
+ settings['hooks'][hook_type].append(hook_dict)
895
1214
 
896
1215
  # Write settings atomically
897
1216
  try:
@@ -908,16 +1227,21 @@ def setup_hooks():
908
1227
  def verify_hooks_installed(settings_path):
909
1228
  """Verify that HCOM hooks were installed correctly"""
910
1229
  try:
911
- with open(settings_path, 'r') as f:
912
- settings = json.load(f)
913
-
914
- # Check all 4 hook types exist with HCOM commands
1230
+ settings = read_file_with_retry(
1231
+ settings_path,
1232
+ lambda f: json.load(f),
1233
+ default=None
1234
+ )
1235
+ if not settings:
1236
+ return False
1237
+
1238
+ # Check all hook types exist with HCOM commands
915
1239
  hooks = settings.get('hooks', {})
916
- for hook_type in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
917
- if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
1240
+ for hook_type in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification']:
1241
+ if not any('hcom' in str(h).lower() or 'HCOM' in str(h)
918
1242
  for h in hooks.get(hook_type, [])):
919
1243
  return False
920
-
1244
+
921
1245
  return True
922
1246
  except Exception:
923
1247
  return False
@@ -930,86 +1254,101 @@ def get_archive_timestamp():
930
1254
  """Get timestamp for archive files"""
931
1255
  return datetime.now().strftime("%Y-%m-%d_%H%M%S")
932
1256
 
933
- def get_conversation_uuid(transcript_path):
934
- """Get conversation UUID from transcript
935
-
936
- For resumed sessions, the first line may be a summary with a different leafUuid.
937
- We need to find the first user entry which contains the stable conversation UUID.
938
- """
939
- try:
940
- if not transcript_path or not os.path.exists(transcript_path):
941
- return None
942
-
943
- # First, try to find the UUID from the first user entry
944
- with open(transcript_path, 'r') as f:
945
- for line in f:
946
- line = line.strip()
947
- if not line:
948
- continue
949
- try:
950
- entry = json.loads(line)
951
- # Look for first user entry with a UUID - this is the stable identifier
952
- if entry.get('type') == 'user' and entry.get('uuid'):
953
- return entry.get('uuid')
954
- except json.JSONDecodeError:
955
- continue
956
-
957
- # Fallback: If no user entry found, try the first line (original behavior)
958
- with open(transcript_path, 'r') as f:
959
- first_line = f.readline().strip()
960
- if first_line:
961
- entry = json.loads(first_line)
962
- # Try both 'uuid' and 'leafUuid' fields
963
- return entry.get('uuid') or entry.get('leafUuid')
964
- except Exception:
965
- pass
966
- return None
967
-
968
1257
  def is_parent_alive(parent_pid=None):
969
1258
  """Check if parent process is alive"""
970
1259
  if parent_pid is None:
971
1260
  parent_pid = os.getppid()
972
-
1261
+
1262
+ # Orphan detection - PID 1 == definitively orphaned
1263
+ if parent_pid == 1:
1264
+ return False
1265
+
1266
+ result = is_process_alive(parent_pid)
1267
+ return result
1268
+
1269
+ def is_process_alive(pid):
1270
+ """Check if a process with given PID exists - cross-platform"""
1271
+ if pid is None:
1272
+ return False
1273
+
1274
+ try:
1275
+ pid = int(pid)
1276
+ except (TypeError, ValueError) as e:
1277
+ return False
1278
+
973
1279
  if IS_WINDOWS:
1280
+ # Windows: Use Windows API to check process existence
974
1281
  try:
975
- import ctypes
976
- kernel32 = ctypes.windll.kernel32
977
- handle = kernel32.OpenProcess(0x0400, False, parent_pid)
978
- if handle == 0:
1282
+ kernel32 = get_windows_kernel32() # Use cached kernel32 instance
1283
+ if not kernel32:
979
1284
  return False
1285
+
1286
+ # Try limited permissions first (more likely to succeed on Vista+)
1287
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
1288
+ error = kernel32.GetLastError()
1289
+
1290
+ if not handle: # Check for None or 0
1291
+ # ERROR_ACCESS_DENIED (5) means process exists but no permission
1292
+ if error == ERROR_ACCESS_DENIED:
1293
+ return True
1294
+
1295
+ # Try fallback with broader permissions for older Windows
1296
+ handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, False, pid)
1297
+
1298
+ if not handle: # Check for None or 0
1299
+ return False # Process doesn't exist or no permission at all
1300
+
1301
+ # Check if process is still running (not just if handle exists)
1302
+ import ctypes.wintypes
1303
+ exit_code = ctypes.wintypes.DWORD()
1304
+ STILL_ACTIVE = 259
1305
+
1306
+ if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
1307
+ kernel32.CloseHandle(handle)
1308
+ is_still_active = exit_code.value == STILL_ACTIVE
1309
+ return is_still_active
1310
+
980
1311
  kernel32.CloseHandle(handle)
981
- return True
982
- except Exception:
983
- return True
1312
+ return False # Couldn't get exit code
1313
+ except Exception as e:
1314
+ return False
984
1315
  else:
1316
+ # Unix: Use os.kill with signal 0
985
1317
  try:
986
- os.kill(parent_pid, 0)
1318
+ os.kill(pid, 0)
987
1319
  return True
988
- except ProcessLookupError:
1320
+ except ProcessLookupError as e:
1321
+ return False
1322
+ except Exception as e:
989
1323
  return False
990
- except Exception:
991
- return True
992
1324
 
993
- def parse_log_messages(log_file, start_pos=0):
994
- """Parse messages from log file"""
995
- log_file = Path(log_file)
1325
+ def parse_log_messages(log_file, start_pos=0, return_end_pos=False):
1326
+ """Parse messages from log file
1327
+ Args:
1328
+ log_file: Path to log file
1329
+ start_pos: Position to start reading from
1330
+ return_end_pos: If True, return tuple (messages, end_position)
1331
+ Returns:
1332
+ list of messages, or (messages, end_pos) if return_end_pos=True
1333
+ """
996
1334
  if not log_file.exists():
997
- return []
998
-
999
- messages = []
1000
- with open(log_file, 'r') as f:
1335
+ return ([], start_pos) if return_end_pos else []
1336
+
1337
+ def read_messages(f):
1001
1338
  f.seek(start_pos)
1002
1339
  content = f.read()
1003
-
1340
+ end_pos = f.tell() # Capture actual end position
1341
+
1004
1342
  if not content.strip():
1005
- return []
1006
-
1343
+ return ([], end_pos)
1344
+
1345
+ messages = []
1007
1346
  message_entries = TIMESTAMP_SPLIT_PATTERN.split(content.strip())
1008
-
1347
+
1009
1348
  for entry in message_entries:
1010
1349
  if not entry or '|' not in entry:
1011
1350
  continue
1012
-
1351
+
1013
1352
  parts = entry.split('|', 2)
1014
1353
  if len(parts) == 3:
1015
1354
  timestamp, from_instance, message = parts
@@ -1018,27 +1357,35 @@ def parse_log_messages(log_file, start_pos=0):
1018
1357
  'from': from_instance.replace('\\|', '|'),
1019
1358
  'message': message.replace('\\|', '|')
1020
1359
  })
1021
-
1022
- return messages
1360
+
1361
+ return (messages, end_pos)
1362
+
1363
+ result = read_file_with_retry(
1364
+ log_file,
1365
+ read_messages,
1366
+ default=([], start_pos)
1367
+ )
1368
+
1369
+ return result if return_end_pos else result[0]
1023
1370
 
1024
1371
  def get_new_messages(instance_name):
1025
1372
  """Get new messages for instance with @-mention filtering"""
1026
- ensure_hcom_dir()
1027
- log_file = get_hcom_dir() / "hcom.log"
1028
-
1373
+ log_file = hcom_path(LOG_FILE, ensure_parent=True)
1374
+
1029
1375
  if not log_file.exists():
1030
1376
  return []
1031
-
1377
+
1032
1378
  positions = load_all_positions()
1033
-
1379
+
1034
1380
  # Get last position for this instance
1035
1381
  last_pos = 0
1036
1382
  if instance_name in positions:
1037
1383
  pos_data = positions.get(instance_name, {})
1038
1384
  last_pos = pos_data.get('pos', 0) if isinstance(pos_data, dict) else pos_data
1039
-
1040
- all_messages = parse_log_messages(log_file, last_pos)
1041
-
1385
+
1386
+ # Atomic read with position tracking
1387
+ all_messages, new_pos = parse_log_messages(log_file, last_pos, return_end_pos=True)
1388
+
1042
1389
  # Filter messages:
1043
1390
  # 1. Exclude own messages
1044
1391
  # 2. Apply @-mention filtering
@@ -1048,15 +1395,10 @@ def get_new_messages(instance_name):
1048
1395
  if msg['from'] != instance_name:
1049
1396
  if should_deliver_message(msg, instance_name, all_instance_names):
1050
1397
  messages.append(msg)
1051
-
1052
- # Update position to end of file
1053
- with open(log_file, 'r') as f:
1054
- f.seek(0, 2) # Seek to end
1055
- new_pos = f.tell()
1056
-
1057
- # Update position directly
1398
+
1399
+ # Update position to what was actually processed
1058
1400
  update_instance_position(instance_name, {'pos': new_pos})
1059
-
1401
+
1060
1402
  return messages
1061
1403
 
1062
1404
  def format_age(seconds):
@@ -1070,35 +1412,55 @@ def format_age(seconds):
1070
1412
 
1071
1413
  def get_transcript_status(transcript_path):
1072
1414
  """Parse transcript to determine current Claude state"""
1073
- try:
1074
- if not transcript_path or not os.path.exists(transcript_path):
1075
- return "inactive", "", "", 0
1076
-
1077
- with open(transcript_path, 'r') as f:
1415
+ if not transcript_path or not os.path.exists(transcript_path):
1416
+ return "inactive", "", "", 0
1417
+
1418
+ def read_status(f):
1419
+ # Windows file buffering fix: read entire file to get current content
1420
+ if IS_WINDOWS:
1421
+ # Seek to beginning and read all content to bypass Windows file caching
1422
+ f.seek(0)
1423
+ all_content = f.read()
1424
+ all_lines = all_content.strip().split('\n')
1425
+ lines = all_lines[-5:] if len(all_lines) >= 5 else all_lines
1426
+ else:
1078
1427
  lines = f.readlines()[-5:]
1079
-
1080
- for line in reversed(lines):
1081
- entry = json.loads(line)
1082
- timestamp = datetime.fromisoformat(entry['timestamp']).timestamp()
1083
- age = int(time.time() - timestamp)
1084
-
1085
- if entry['type'] == 'system':
1086
- content = entry.get('content', '')
1087
- if 'Running' in content:
1088
- tool_name = content.split('Running ')[1].split('[')[0].strip()
1089
- return "executing", f"({format_age(age)})", tool_name, timestamp
1090
-
1091
- elif entry['type'] == 'assistant':
1092
- content = entry.get('content', [])
1093
- if any('tool_use' in str(item) for item in content):
1094
- return "executing", f"({format_age(age)})", "tool", timestamp
1095
- else:
1096
- return "responding", f"({format_age(age)})", "", timestamp
1097
-
1098
- elif entry['type'] == 'user':
1099
- return "thinking", f"({format_age(age)})", "", timestamp
1100
-
1428
+
1429
+ for i, line in enumerate(reversed(lines)):
1430
+ try:
1431
+ entry = json.loads(line)
1432
+ timestamp = datetime.fromisoformat(entry['timestamp']).timestamp()
1433
+ age = int(time.time() - timestamp)
1434
+ entry_type = entry.get('type', '')
1435
+
1436
+ if entry['type'] == 'system':
1437
+ content = entry.get('content', '')
1438
+ if 'Running' in content:
1439
+ tool_name = content.split('Running ')[1].split('[')[0].strip()
1440
+ return "executing", f"({format_age(age)})", tool_name, timestamp
1441
+
1442
+ elif entry['type'] == 'assistant':
1443
+ content = entry.get('content', [])
1444
+ has_tool_use = any('tool_use' in str(item) for item in content)
1445
+ if has_tool_use:
1446
+ return "executing", f"({format_age(age)})", "tool", timestamp
1447
+ else:
1448
+ return "responding", f"({format_age(age)})", "", timestamp
1449
+
1450
+ elif entry['type'] == 'user':
1451
+ return "thinking", f"({format_age(age)})", "", timestamp
1452
+ except (json.JSONDecodeError, KeyError, ValueError) as e:
1453
+ continue
1454
+
1101
1455
  return "inactive", "", "", 0
1456
+
1457
+ try:
1458
+ result = read_file_with_retry(
1459
+ transcript_path,
1460
+ read_status,
1461
+ default=("inactive", "", "", 0)
1462
+ )
1463
+ return result
1102
1464
  except Exception:
1103
1465
  return "inactive", "", "", 0
1104
1466
 
@@ -1106,7 +1468,7 @@ def get_instance_status(pos_data):
1106
1468
  """Get current status of instance"""
1107
1469
  now = int(time.time())
1108
1470
  wait_timeout = pos_data.get('wait_timeout', get_config_value('wait_timeout', 1800))
1109
-
1471
+
1110
1472
  # Check if process is still alive. pid: null means killed
1111
1473
  # All real instances should have a PID (set by update_instance_with_pid)
1112
1474
  if 'pid' in pos_data:
@@ -1114,48 +1476,49 @@ def get_instance_status(pos_data):
1114
1476
  if pid is None:
1115
1477
  # Explicitly null = was killed
1116
1478
  return "inactive", ""
1117
- try:
1118
- os.kill(int(pid), 0) # Check process existence
1119
- except (ProcessLookupError, TypeError, ValueError):
1120
- return "inactive", ""
1121
-
1479
+ if not is_process_alive(pid):
1480
+ # On Windows, PID checks can fail during process transitions
1481
+ # Let timeout logic handle this using activity timestamps
1482
+ wait_timeout = 30 if IS_WINDOWS else wait_timeout # Shorter timeout when PID dead
1483
+
1122
1484
  last_permission = pos_data.get("last_permission_request", 0)
1123
1485
  last_stop = pos_data.get("last_stop", 0)
1124
1486
  last_tool = pos_data.get("last_tool", 0)
1125
-
1487
+
1126
1488
  transcript_timestamp = 0
1127
1489
  transcript_status = "inactive"
1128
-
1490
+
1129
1491
  transcript_path = pos_data.get("transcript_path", "")
1130
1492
  if transcript_path:
1131
1493
  status, _, _, transcript_timestamp = get_transcript_status(transcript_path)
1132
1494
  transcript_status = status
1133
-
1495
+
1134
1496
  # Calculate last actual activity (excluding heartbeat)
1135
1497
  last_activity = max(last_permission, last_tool, transcript_timestamp)
1136
-
1498
+
1137
1499
  # Check timeout based on actual activity
1138
1500
  if last_activity > 0 and (now - last_activity) > wait_timeout:
1139
1501
  return "inactive", ""
1140
-
1502
+
1141
1503
  # Now determine current status including heartbeat
1142
1504
  events = [
1143
1505
  (last_permission, "blocked"),
1144
- (last_stop, "waiting"),
1506
+ (last_stop, "waiting"),
1145
1507
  (last_tool, "inactive"),
1146
1508
  (transcript_timestamp, transcript_status)
1147
1509
  ]
1148
-
1510
+
1149
1511
  recent_events = [(ts, status) for ts, status in events if ts > 0]
1150
1512
  if not recent_events:
1151
1513
  return "inactive", ""
1152
-
1514
+
1153
1515
  most_recent_time, most_recent_status = max(recent_events)
1154
1516
  age = now - most_recent_time
1155
-
1156
- # Add background indicator
1517
+
1157
1518
  status_suffix = " (bg)" if pos_data.get('background') else ""
1158
- return most_recent_status, f"({format_age(age)}){status_suffix}"
1519
+ final_result = (most_recent_status, f"({format_age(age)}){status_suffix}")
1520
+
1521
+ return final_result
1159
1522
 
1160
1523
  def get_status_block(status_type):
1161
1524
  """Get colored status block for a status type"""
@@ -1206,28 +1569,18 @@ def show_recent_activity_alt_screen(limit=None):
1206
1569
  available_height = get_terminal_height() - 20
1207
1570
  limit = max(2, available_height // 2)
1208
1571
 
1209
- log_file = get_hcom_dir() / 'hcom.log'
1572
+ log_file = hcom_path(LOG_FILE)
1210
1573
  if log_file.exists():
1211
1574
  messages = parse_log_messages(log_file)
1212
1575
  show_recent_messages(messages, limit, truncate=True)
1213
1576
 
1214
- def show_instances_status():
1215
- """Show status of all instances"""
1577
+ def show_instances_by_directory():
1578
+ """Show instances organized by their working directories"""
1216
1579
  positions = load_all_positions()
1217
1580
  if not positions:
1218
1581
  print(f" {DIM}No Claude instances connected{RESET}")
1219
1582
  return
1220
-
1221
- print("Instances in hcom:")
1222
- for instance_name, pos_data in positions.items():
1223
- status_type, age = get_instance_status(pos_data)
1224
- status_block = get_status_block(status_type)
1225
- directory = pos_data.get("directory", "unknown")
1226
- print(f" {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}{RESET} {directory}")
1227
-
1228
- def show_instances_by_directory():
1229
- """Show instances organized by their working directories"""
1230
- positions = load_all_positions()
1583
+
1231
1584
  if positions:
1232
1585
  directories = {}
1233
1586
  for instance_name, pos_data in positions.items():
@@ -1245,7 +1598,9 @@ def show_instances_by_directory():
1245
1598
  last_tool_name = pos_data.get("last_tool_name", "unknown")
1246
1599
  last_tool_str = datetime.fromtimestamp(last_tool).strftime("%H:%M:%S") if last_tool else "unknown"
1247
1600
 
1248
- sid = pos_data.get("session_id", "")
1601
+ # Get session IDs (already migrated to array format)
1602
+ session_ids = pos_data.get("session_ids", [])
1603
+ sid = session_ids[-1] if session_ids else "" # Show most recent session
1249
1604
  session_info = f" | {sid}" if sid else ""
1250
1605
 
1251
1606
  print(f" {FG_GREEN}->{RESET} {BOLD}{instance_name}{RESET} {status_block} {DIM}{status_type} {age}- used {last_tool_name} at {last_tool_str}{session_info}{RESET}")
@@ -1262,7 +1617,7 @@ def alt_screen_detailed_status_and_input():
1262
1617
  print(f"{BOLD} HCOM DETAILED STATUS{RESET}")
1263
1618
  print(f"{BOLD}{'=' * 70}{RESET}")
1264
1619
  print(f"{FG_CYAN} HCOM: GLOBAL CHAT{RESET}")
1265
- print(f"{DIM} LOG FILE: {get_hcom_dir() / 'hcom.log'}{RESET}")
1620
+ print(f"{DIM} LOG FILE: {hcom_path(LOG_FILE)}{RESET}")
1266
1621
  print(f"{DIM} UPDATED: {timestamp}{RESET}")
1267
1622
  print(f"{BOLD}{'-' * 70}{RESET}")
1268
1623
  print()
@@ -1291,26 +1646,28 @@ def get_status_summary():
1291
1646
  positions = load_all_positions()
1292
1647
  if not positions:
1293
1648
  return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1294
-
1649
+
1295
1650
  status_counts = {"thinking": 0, "responding": 0, "executing": 0, "waiting": 0, "blocked": 0, "inactive": 0}
1296
-
1297
- for _, pos_data in positions.items():
1651
+
1652
+ for instance_name, pos_data in positions.items():
1298
1653
  status_type, _ = get_instance_status(pos_data)
1299
1654
  if status_type in status_counts:
1300
1655
  status_counts[status_type] += 1
1301
-
1656
+
1302
1657
  parts = []
1303
1658
  status_order = ["thinking", "responding", "executing", "waiting", "blocked", "inactive"]
1304
-
1659
+
1305
1660
  for status_type in status_order:
1306
1661
  count = status_counts[status_type]
1307
1662
  if count > 0:
1308
1663
  color, symbol = STATUS_MAP[status_type]
1309
1664
  text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
1310
- parts.append(f"{text_color}{BOLD}{color} {count} {symbol} {RESET}")
1311
-
1665
+ part = f"{text_color}{BOLD}{color} {count} {symbol} {RESET}"
1666
+ parts.append(part)
1667
+
1312
1668
  if parts:
1313
- return "".join(parts)
1669
+ result = "".join(parts)
1670
+ return result
1314
1671
  else:
1315
1672
  return f"{BG_BLUE}{BOLD}{FG_WHITE} no instances {RESET}"
1316
1673
 
@@ -1325,79 +1682,130 @@ def log_line_with_status(message, status):
1325
1682
  sys.stdout.write("\033[K" + status)
1326
1683
  sys.stdout.flush()
1327
1684
 
1328
- def initialize_instance_in_position_file(instance_name, conversation_uuid=None):
1329
- """Initialize an instance in the position file with all required fields"""
1330
- instance_file = get_instance_file(instance_name)
1331
- if not instance_file.exists():
1332
- save_instance_position(instance_name, {
1685
+ def initialize_instance_in_position_file(instance_name, session_id=None):
1686
+ """Initialize instance file with required fields (idempotent). Returns True on success, False on failure."""
1687
+ try:
1688
+ data = load_instance_position(instance_name)
1689
+
1690
+ defaults = {
1333
1691
  "pos": 0,
1334
1692
  "directory": str(Path.cwd()),
1335
- "conversation_uuid": conversation_uuid or "unknown",
1336
1693
  "last_tool": 0,
1337
1694
  "last_tool_name": "unknown",
1338
1695
  "last_stop": 0,
1339
1696
  "last_permission_request": 0,
1697
+ "session_ids": [session_id] if session_id else [],
1340
1698
  "transcript_path": "",
1341
- "session_id": "",
1342
- "help_shown": False,
1343
- "notification_message": ""
1344
- })
1345
-
1346
- def migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path):
1347
- """Migrate instance name from fallback to UUID-based if needed"""
1348
- if instance_name.endswith("claude") and conversation_uuid:
1349
- new_instance = get_display_name(transcript_path)
1350
- if new_instance != instance_name and not new_instance.endswith("claude"):
1351
- # Migration of data only happens if old name exists
1352
- old_file = get_instance_file(instance_name)
1353
- if old_file.exists():
1354
- data = load_instance_position(instance_name)
1355
- data["conversation_uuid"] = conversation_uuid
1356
- save_instance_position(new_instance, data)
1357
- old_file.unlink()
1358
- return new_instance
1359
- return instance_name
1699
+ "notification_message": "",
1700
+ "alias_announced": False
1701
+ }
1702
+
1703
+ # Add missing fields (preserve existing)
1704
+ for key, value in defaults.items():
1705
+ data.setdefault(key, value)
1706
+
1707
+ return save_instance_position(instance_name, data)
1708
+ except Exception:
1709
+ return False
1360
1710
 
1361
1711
  def update_instance_position(instance_name, update_fields):
1362
- """Update instance position - direct file write, no locking needed"""
1363
- data = load_instance_position(instance_name)
1364
- data.update(update_fields)
1365
- save_instance_position(instance_name, data)
1712
+ """Update instance position (with NEW and IMPROVED Windows file locking tolerance!!)"""
1713
+ try:
1714
+ data = load_instance_position(instance_name)
1366
1715
 
1367
- def get_first_use_text(instance_name):
1368
- """Get first-use help text if not shown yet
1369
-
1370
- Returns:
1371
- str: Welcome text if not shown before, empty string otherwise
1372
- """
1373
- positions = load_all_positions()
1374
-
1375
- instance_data = positions.get(instance_name, {})
1376
- if not instance_data.get('help_shown', False):
1377
- # Mark help as shown
1378
- update_instance_position(instance_name, {'help_shown': True})
1379
-
1380
- # Get values using unified config system
1381
- first_use_text = get_config_value('first_use_text', '')
1382
- instance_hints = get_config_value('instance_hints', '')
1383
-
1384
- help_text = f"[Welcome! hcom chat active. Your alias: {instance_name}. " \
1385
- f"Send messages: echo 'HCOM_SEND:your message']"
1386
- if first_use_text:
1387
- help_text += f" [{first_use_text}]"
1388
- if instance_hints:
1389
- help_text += f" [{instance_hints}]"
1390
-
1391
- return help_text
1392
- return ""
1716
+ if not data: # If file empty/missing, initialize first
1717
+ initialize_instance_in_position_file(instance_name)
1718
+ data = load_instance_position(instance_name)
1719
+
1720
+ data.update(update_fields)
1721
+ save_instance_position(instance_name, data)
1722
+ except PermissionError: # Expected on Windows during file locks, silently continue
1723
+ pass
1724
+ except Exception: # Other exceptions on Windows may also be file locking related
1725
+ if IS_WINDOWS:
1726
+ pass
1727
+ else:
1728
+ raise
1729
+
1730
+ def merge_instance_data(to_data, from_data):
1731
+ """Merge instance data from from_data into to_data."""
1732
+ # Merge session_ids arrays with deduplication
1733
+ to_sessions = to_data.get('session_ids', [])
1734
+ from_sessions = from_data.get('session_ids', [])
1735
+ to_data['session_ids'] = list(dict.fromkeys(to_sessions + from_sessions))
1736
+
1737
+ # Update transient fields from source
1738
+ to_data['pid'] = os.getppid() # Always use current PID
1739
+ to_data['transcript_path'] = from_data.get('transcript_path', to_data.get('transcript_path', ''))
1740
+
1741
+ # Preserve maximum position
1742
+ to_data['pos'] = max(to_data.get('pos', 0), from_data.get('pos', 0))
1743
+
1744
+ # Update directory to most recent
1745
+ to_data['directory'] = from_data.get('directory', to_data.get('directory', str(Path.cwd())))
1746
+
1747
+ # Update last activity timestamps to most recent
1748
+ to_data['last_tool'] = max(to_data.get('last_tool', 0), from_data.get('last_tool', 0))
1749
+ to_data['last_tool_name'] = from_data.get('last_tool_name', to_data.get('last_tool_name', 'unknown'))
1750
+ to_data['last_stop'] = max(to_data.get('last_stop', 0), from_data.get('last_stop', 0))
1751
+ to_data['last_permission_request'] = max(
1752
+ to_data.get('last_permission_request', 0),
1753
+ from_data.get('last_permission_request', 0)
1754
+ )
1755
+
1756
+ # Preserve background mode if set
1757
+ to_data['background'] = to_data.get('background') or from_data.get('background')
1758
+ if from_data.get('background_log_file'):
1759
+ to_data['background_log_file'] = from_data['background_log_file']
1760
+
1761
+ return to_data
1762
+
1763
+ def terminate_process(pid, force=False):
1764
+ """Cross-platform process termination"""
1765
+ try:
1766
+ if IS_WINDOWS:
1767
+ cmd = ['taskkill', '/PID', str(pid)]
1768
+ if force:
1769
+ cmd.insert(1, '/F')
1770
+ subprocess.run(cmd, capture_output=True, check=True)
1771
+ else:
1772
+ os.kill(pid, 9 if force else 15) # SIGKILL or SIGTERM
1773
+ return True
1774
+ except (ProcessLookupError, OSError, subprocess.CalledProcessError):
1775
+ return False # Process already dead
1776
+
1777
+ def merge_instance_immediately(from_name, to_name):
1778
+ """Merge from_name into to_name with safety checks. Returns success message or error message."""
1779
+ if from_name == to_name:
1780
+ return ""
1781
+
1782
+ try:
1783
+ from_data = load_instance_position(from_name)
1784
+ to_data = load_instance_position(to_name)
1785
+
1786
+ # Check if target is active
1787
+ if to_data.get('pid'):
1788
+ if is_process_alive(to_data['pid']):
1789
+ return f"Cannot recover {to_name}: instance is active"
1790
+ # Process is dead, safe to merge
1791
+
1792
+ # Merge data using helper
1793
+ to_data = merge_instance_data(to_data, from_data)
1794
+
1795
+ # Save merged data - check for success
1796
+ if not save_instance_position(to_name, to_data):
1797
+ return f"Failed to save merged data for {to_name}"
1798
+
1799
+ # Cleanup source file only after successful save
1800
+ try:
1801
+ hcom_path(INSTANCES_DIR, f"{from_name}.json").unlink()
1802
+ except:
1803
+ pass # Non-critical if cleanup fails
1804
+
1805
+ return f"[SUCCESS] ✓ Recovered: {from_name} → {to_name}"
1806
+ except Exception:
1807
+ return f"Failed to merge {from_name} into {to_name}"
1393
1808
 
1394
- def check_and_show_first_use_help(instance_name):
1395
- """Check and show first-use help if needed (for Stop hook)"""
1396
- help_text = get_first_use_text(instance_name)
1397
- if help_text:
1398
- output = {"decision": HOOK_DECISION_BLOCK, "reason": help_text}
1399
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
1400
- sys.exit(EXIT_BLOCK)
1401
1809
 
1402
1810
  # ==================== Command Functions ====================
1403
1811
 
@@ -1405,7 +1813,7 @@ def show_main_screen_header():
1405
1813
  """Show header for main screen"""
1406
1814
  sys.stdout.write("\033[2J\033[H")
1407
1815
 
1408
- log_file = get_hcom_dir() / 'hcom.log'
1816
+ log_file = hcom_path(LOG_FILE)
1409
1817
  all_messages = []
1410
1818
  if log_file.exists():
1411
1819
  all_messages = parse_log_messages(log_file)
@@ -1440,6 +1848,7 @@ Usage:
1440
1848
  hcom open <agent> Launch named agent from .claude/agents/
1441
1849
  hcom open --prefix <team> n Launch n instances with team prefix
1442
1850
  hcom open --background Launch instances as background processes (-p also works)
1851
+ hcom open --claude-args "--model sonnet" Pass claude code CLI flags
1443
1852
  hcom watch View conversation dashboard
1444
1853
  hcom clear Clear and archive conversation
1445
1854
  hcom cleanup Remove hooks from current directory
@@ -1456,9 +1865,9 @@ Automation:
1456
1865
  hcom watch --wait [seconds] Wait for new messages (default 60s)
1457
1866
 
1458
1867
  Docs: https://raw.githubusercontent.com/aannoo/claude-hook-comms/main/README.md""")
1459
-
1460
- # Additional help for AI assistants when running in non-interactive mode
1461
- if not sys.stdin.isatty():
1868
+
1869
+ # Additional help for AI assistants
1870
+ if os.environ.get('CLAUDECODE') == '1' or not sys.stdin.isatty():
1462
1871
  print("""
1463
1872
 
1464
1873
  === ADDITIONAL INFO ===
@@ -1497,22 +1906,28 @@ STATUS INDICATORS:
1497
1906
  • ○ inactive - instance is timed out, disconnected, etc
1498
1907
 
1499
1908
  CONFIG:
1500
- Config file (persistent s): ~/.hcom/config.json
1909
+ Config file (persistent): ~/.hcom/config.json
1501
1910
 
1502
1911
  Key settings (full list in config.json):
1503
1912
  terminal_mode: "new_window" (default) | "same_terminal" | "show_commands"
1504
1913
  initial_prompt: "Say hi in chat", first_use_text: "Essential messages only..."
1505
1914
  instance_hints: "text", cli_hints: "text" # Extra info for instances/CLI
1915
+ env_overrides: "custom environment variables for instances"
1506
1916
 
1507
1917
  Temporary environment overrides for any setting (all caps & append HCOM_):
1508
1918
  HCOM_INSTANCE_HINTS="useful info" hcom open # applied to all messages recieved by instance
1509
1919
  export HCOM_CLI_HINTS="useful info" && hcom send 'hi' # applied to all cli commands
1510
1920
 
1511
1921
  EXPECT: hcom instance aliases are auto-generated (5-char format: "hova7"). Check actual aliases
1512
- with 'hcom watch --status'. Instances respond automatically in shared chat.""")
1513
-
1922
+ with 'hcom watch --status'. Instances respond automatically in shared chat.
1923
+
1924
+ Run 'claude --help' to see all claude code CLI flags.""")
1925
+
1514
1926
  show_cli_hints(to_stderr=False)
1515
-
1927
+ else:
1928
+ if not IS_WINDOWS:
1929
+ print("\nFor additional info & examples: hcom --help | cat")
1930
+
1516
1931
  return 0
1517
1932
 
1518
1933
  def cmd_open(*args):
@@ -1520,7 +1935,15 @@ def cmd_open(*args):
1520
1935
  try:
1521
1936
  # Parse arguments
1522
1937
  instances, prefix, claude_args, background = parse_open_args(list(args))
1523
-
1938
+
1939
+ # Extract resume sessionId if present
1940
+ resume_session_id = None
1941
+ if claude_args:
1942
+ for i, arg in enumerate(claude_args):
1943
+ if arg in ['--resume', '-r'] and i + 1 < len(claude_args):
1944
+ resume_session_id = claude_args[i + 1]
1945
+ break
1946
+
1524
1947
  # Add -p flag and stream-json output for background mode if not already present
1525
1948
  if background and '-p' not in claude_args and '--print' not in claude_args:
1526
1949
  claude_args = ['-p', '--output-format', 'stream-json', '--verbose'] + (claude_args or [])
@@ -1541,23 +1964,29 @@ def cmd_open(*args):
1541
1964
  print(format_error(f"Failed to setup hooks: {e}"), file=sys.stderr)
1542
1965
  return 1
1543
1966
 
1544
- ensure_hcom_dir()
1545
- log_file = get_hcom_dir() / 'hcom.log'
1546
- instances_dir = get_hcom_dir() / 'instances'
1967
+ log_file = hcom_path(LOG_FILE, ensure_parent=True)
1968
+ instances_dir = hcom_path(INSTANCES_DIR)
1969
+ instances_dir.mkdir(exist_ok=True)
1547
1970
 
1548
1971
  if not log_file.exists():
1549
1972
  log_file.touch()
1550
- if not instances_dir.exists():
1551
- instances_dir.mkdir(exist_ok=True)
1552
1973
 
1553
1974
  # Build environment variables for Claude instances
1554
1975
  base_env = build_claude_env()
1555
-
1976
+
1977
+ # Pass resume sessionId to hooks (only for first instance if multiple)
1978
+ # This avoids conflicts when resuming with -n > 1
1979
+ if resume_session_id:
1980
+ if len(instances) > 1:
1981
+ print(f"Warning: --resume with {len(instances)} instances will only resume the first instance", file=sys.stderr)
1982
+ # Will be added to first instance env only
1983
+
1556
1984
  # Add prefix-specific hints if provided
1557
1985
  if prefix:
1986
+ base_env['HCOM_PREFIX'] = prefix
1558
1987
  hint = f"To respond to {prefix} group: echo 'HCOM_SEND:@{prefix} message'"
1559
1988
  base_env['HCOM_INSTANCE_HINTS'] = hint
1560
-
1989
+
1561
1990
  first_use = f"You're in the {prefix} group. Use {prefix} to message: echo HCOM_SEND:@{prefix} message."
1562
1991
  base_env['HCOM_FIRST_USE_TEXT'] = first_use
1563
1992
 
@@ -1566,8 +1995,12 @@ def cmd_open(*args):
1566
1995
 
1567
1996
  temp_files_to_cleanup = []
1568
1997
 
1569
- for instance_type in instances:
1998
+ for idx, instance_type in enumerate(instances):
1570
1999
  instance_env = base_env.copy()
2000
+
2001
+ # Add resume sessionId only to first instance when multiple instances
2002
+ if resume_session_id and idx == 0:
2003
+ instance_env['HCOM_RESUME_SESSION_ID'] = resume_session_id
1571
2004
 
1572
2005
  # Mark background instances via environment with log filename
1573
2006
  if background:
@@ -1616,21 +2049,11 @@ def cmd_open(*args):
1616
2049
  launch_terminal(claude_cmd, instance_env, cwd=os.getcwd())
1617
2050
  launched += 1
1618
2051
  except Exception as e:
1619
- print(f"Error: Failed to launch terminal: {e}", file=sys.stderr)
2052
+ print(format_error(f"Failed to launch terminal: {e}"), file=sys.stderr)
1620
2053
 
1621
2054
  # Clean up temp files after a delay (let terminals read them first)
1622
2055
  if temp_files_to_cleanup:
1623
- def cleanup_temp_files():
1624
- time.sleep(5) # Give terminals time to read the files
1625
- for temp_file in temp_files_to_cleanup:
1626
- try:
1627
- os.unlink(temp_file)
1628
- except:
1629
- pass
1630
-
1631
- cleanup_thread = threading.Thread(target=cleanup_temp_files)
1632
- cleanup_thread.daemon = True
1633
- cleanup_thread.start()
2056
+ schedule_file_cleanup(temp_files_to_cleanup)
1634
2057
 
1635
2058
  if launched == 0:
1636
2059
  print(format_error("No instances launched"), file=sys.stderr)
@@ -1638,34 +2061,45 @@ def cmd_open(*args):
1638
2061
 
1639
2062
  # Success message
1640
2063
  print(f"Launched {launched} Claude instance{'s' if launched != 1 else ''}")
1641
-
1642
- tips = [
1643
- "Run 'hcom watch' to view/send in conversation dashboard",
1644
- ]
1645
- if prefix:
1646
- tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
1647
-
1648
- print("\n" + "\n".join(f" • {tip}" for tip in tips))
1649
-
1650
- # Show cli_hints if configured (non-interactive mode)
1651
- if not is_interactive():
1652
- cli_hints = get_config_value('cli_hints', '')
1653
- if cli_hints:
1654
- print(f"\n{cli_hints}")
1655
-
1656
- return 0
2064
+
2065
+ # Auto-launch watch dashboard if configured and conditions are met
2066
+ terminal_mode = get_config_value('terminal_mode')
2067
+ auto_watch = get_config_value('auto_watch', True)
2068
+
2069
+ if terminal_mode == 'new_window' and auto_watch and launched > 0 and is_interactive():
2070
+ # Show tips first if needed
2071
+ if prefix:
2072
+ print(f"\n • Send to {prefix} team: hcom send '@{prefix} message'")
2073
+
2074
+ # Launch interactive watch dashboard in current terminal
2075
+ return cmd_watch()
2076
+ else:
2077
+ tips = [
2078
+ "Run 'hcom watch' to view/send in conversation dashboard",
2079
+ ]
2080
+ if prefix:
2081
+ tips.append(f"Send to {prefix} team: hcom send '@{prefix} message'")
2082
+
2083
+ if tips:
2084
+ print("\n" + "\n".join(f" • {tip}" for tip in tips))
2085
+
2086
+ # Show cli_hints if configured (non-interactive mode)
2087
+ if not is_interactive():
2088
+ show_cli_hints(to_stderr=False)
2089
+
2090
+ return 0
1657
2091
 
1658
2092
  except ValueError as e:
1659
- print(f"Error: {e}", file=sys.stderr)
2093
+ print(str(e), file=sys.stderr)
1660
2094
  return 1
1661
2095
  except Exception as e:
1662
- print(f"Error: {e}", file=sys.stderr)
2096
+ print(str(e), file=sys.stderr)
1663
2097
  return 1
1664
2098
 
1665
2099
  def cmd_watch(*args):
1666
2100
  """View conversation dashboard"""
1667
- log_file = get_hcom_dir() / 'hcom.log'
1668
- instances_dir = get_hcom_dir() / 'instances'
2101
+ log_file = hcom_path(LOG_FILE)
2102
+ instances_dir = hcom_path(INSTANCES_DIR)
1669
2103
 
1670
2104
  if not log_file.exists() and not instances_dir.exists():
1671
2105
  print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
@@ -1721,19 +2155,23 @@ def cmd_watch(*args):
1721
2155
  else:
1722
2156
  print(f'---Waiting for new message... (exits on receipt or after {wait_timeout} seconds)---', file=sys.stderr)
1723
2157
 
2158
+
1724
2159
  # Wait loop
1725
2160
  start_time = time.time()
1726
2161
  while time.time() - start_time < wait_timeout:
1727
- if log_file.exists() and log_file.stat().st_size > last_pos:
1728
- # Capture new position BEFORE parsing (atomic)
1729
- new_pos = log_file.stat().st_size
1730
- new_messages = parse_log_messages(log_file, last_pos)
2162
+ if log_file.exists():
2163
+ current_size = log_file.stat().st_size
2164
+ new_messages = []
2165
+ if current_size > last_pos:
2166
+ # Capture new position BEFORE parsing (atomic)
2167
+ new_messages = parse_log_messages(log_file, last_pos)
1731
2168
  if new_messages:
1732
2169
  for msg in new_messages:
1733
2170
  print(f"[{msg['timestamp']}] {msg['from']}: {msg['message']}")
1734
- last_pos = new_pos # Update only after successful processing
2171
+ last_pos = current_size # Update only after successful processing
1735
2172
  return 0 # Success - got new messages
1736
- last_pos = new_pos # Update even if no messages (file grew but no complete messages yet)
2173
+ if current_size > last_pos:
2174
+ last_pos = current_size # Update even if no messages (file grew but no complete messages yet)
1737
2175
  time.sleep(0.1)
1738
2176
 
1739
2177
  # Timeout message to stderr
@@ -1751,19 +2189,46 @@ def cmd_watch(*args):
1751
2189
  show_cli_hints()
1752
2190
 
1753
2191
  elif show_status:
1754
- # Status information to stdout for parsing
1755
- print("HCOM STATUS")
1756
- print("INSTANCES:")
1757
- show_instances_status()
1758
- print("\nRECENT ACTIVITY:")
1759
- show_recent_activity_alt_screen()
1760
- print(f"\nLOG FILE: {log_file}")
1761
-
2192
+ # Build JSON output
2193
+ positions = load_all_positions()
2194
+
2195
+ instances = {}
2196
+ status_counts = {}
2197
+
2198
+ for name, data in positions.items():
2199
+ status, age = get_instance_status(data)
2200
+ instances[name] = {
2201
+ "status": status,
2202
+ "age": age.strip() if age else "",
2203
+ "directory": data.get("directory", "unknown"),
2204
+ "session_ids": data.get("session_ids", []),
2205
+ "last_tool": data.get("last_tool_name", "unknown"),
2206
+ "last_tool_time": data.get("last_tool", 0),
2207
+ "pid": data.get("pid"),
2208
+ "background": bool(data.get("background"))
2209
+ }
2210
+ status_counts[status] = status_counts.get(status, 0) + 1
2211
+
2212
+ # Get recent messages
2213
+ messages = []
2214
+ if log_file.exists():
2215
+ all_messages = parse_log_messages(log_file)
2216
+ messages = all_messages[-5:] if all_messages else []
2217
+
2218
+ # Output JSON
2219
+ output = {
2220
+ "instances": instances,
2221
+ "recent_messages": messages,
2222
+ "status_summary": status_counts,
2223
+ "log_file": str(log_file),
2224
+ "timestamp": datetime.now().isoformat()
2225
+ }
2226
+
2227
+ print(json.dumps(output, indent=2))
1762
2228
  show_cli_hints()
1763
2229
  else:
1764
- # No TTY - show automation usage to stderr
1765
- print("Automation usage:", file=sys.stderr)
1766
- print(" hcom send 'message' Send message to group", file=sys.stderr)
2230
+ print("No TTY - Automation usage:", file=sys.stderr)
2231
+ print(" hcom send 'message' Send message to chat", file=sys.stderr)
1767
2232
  print(" hcom watch --logs Show message history", file=sys.stderr)
1768
2233
  print(" hcom watch --status Show instance status", file=sys.stderr)
1769
2234
  print(" hcom watch --wait Wait for new messages", file=sys.stderr)
@@ -1798,7 +2263,7 @@ def cmd_watch(*args):
1798
2263
  try:
1799
2264
  while True:
1800
2265
  now = time.time()
1801
- if now - last_status_update > 2.0:
2266
+ if now - last_status_update > 0.1: # 100ms
1802
2267
  current_status = get_status_summary()
1803
2268
 
1804
2269
  # Only redraw if status text changed
@@ -1808,13 +2273,15 @@ def cmd_watch(*args):
1808
2273
 
1809
2274
  last_status_update = now
1810
2275
 
1811
- if log_file.exists() and log_file.stat().st_size > last_pos:
1812
- new_messages = parse_log_messages(log_file, last_pos)
1813
- # Use the last known status for consistency
1814
- status_line_text = f"{last_status}{status_suffix}"
1815
- for msg in new_messages:
1816
- log_line_with_status(format_message_line(msg), status_line_text)
1817
- last_pos = log_file.stat().st_size
2276
+ if log_file.exists():
2277
+ current_size = log_file.stat().st_size
2278
+ if current_size > last_pos:
2279
+ new_messages = parse_log_messages(log_file, last_pos)
2280
+ # Use the last known status for consistency
2281
+ status_line_text = f"{last_status}{status_suffix}"
2282
+ for msg in new_messages:
2283
+ log_line_with_status(format_message_line(msg), status_line_text)
2284
+ last_pos = current_size
1818
2285
 
1819
2286
  # Check for keyboard input
1820
2287
  ready_for_input = False
@@ -1860,38 +2327,38 @@ def cmd_watch(*args):
1860
2327
 
1861
2328
  def cmd_clear():
1862
2329
  """Clear and archive conversation"""
1863
- ensure_hcom_dir()
1864
- log_file = get_hcom_dir() / 'hcom.log'
1865
- instances_dir = get_hcom_dir() / 'instances'
1866
- archive_folder = get_hcom_dir() / 'archive'
1867
-
2330
+ log_file = hcom_path(LOG_FILE, ensure_parent=True)
2331
+ instances_dir = hcom_path(INSTANCES_DIR)
2332
+ archive_folder = hcom_path(ARCHIVE_DIR)
2333
+ archive_folder.mkdir(exist_ok=True)
2334
+
2335
+ # Clean up temp files from failed atomic writes
2336
+ if instances_dir.exists():
2337
+ deleted_count = sum(1 for f in instances_dir.glob('*.tmp') if f.unlink(missing_ok=True) is None)
2338
+ if deleted_count > 0:
2339
+ print(f"Cleaned up {deleted_count} temp files")
2340
+
1868
2341
  # Check if hcom files exist
1869
2342
  if not log_file.exists() and not instances_dir.exists():
1870
2343
  print("No hcom conversation to clear")
1871
2344
  return 0
1872
-
1873
- # Generate archive timestamp
1874
- timestamp = get_archive_timestamp()
1875
-
1876
- # Ensure archive folder exists
1877
- archive_folder.mkdir(exist_ok=True)
1878
-
2345
+
1879
2346
  # Archive existing files if they have content
2347
+ timestamp = get_archive_timestamp()
1880
2348
  archived = False
1881
-
2349
+
1882
2350
  try:
1883
- # Create session archive folder if we have content to archive
1884
2351
  has_log = log_file.exists() and log_file.stat().st_size > 0
1885
2352
  has_instances = instances_dir.exists() and any(instances_dir.glob('*.json'))
1886
2353
 
1887
2354
  if has_log or has_instances:
1888
2355
  # Create session archive folder with timestamp
1889
- session_archive = archive_folder / f'session-{timestamp}'
2356
+ session_archive = hcom_path(ARCHIVE_DIR, f'session-{timestamp}')
1890
2357
  session_archive.mkdir(exist_ok=True)
1891
2358
 
1892
2359
  # Archive log file
1893
2360
  if has_log:
1894
- archive_log = session_archive / 'hcom.log'
2361
+ archive_log = session_archive / LOG_FILE
1895
2362
  log_file.rename(archive_log)
1896
2363
  archived = True
1897
2364
  elif log_file.exists():
@@ -1899,10 +2366,10 @@ def cmd_clear():
1899
2366
 
1900
2367
  # Archive instances
1901
2368
  if has_instances:
1902
- archive_instances = session_archive / 'instances'
2369
+ archive_instances = session_archive / INSTANCES_DIR
1903
2370
  archive_instances.mkdir(exist_ok=True)
1904
2371
 
1905
- # Move json files only (background logs stay in logs folder)
2372
+ # Move json files only
1906
2373
  for f in instances_dir.glob('*.json'):
1907
2374
  f.rename(archive_instances / f.name)
1908
2375
 
@@ -1939,20 +2406,24 @@ def cleanup_directory_hooks(directory):
1939
2406
 
1940
2407
  try:
1941
2408
  # Load existing settings
1942
- with open(settings_path, 'r') as f:
1943
- settings = json.load(f)
2409
+ settings = read_file_with_retry(
2410
+ settings_path,
2411
+ lambda f: json.load(f),
2412
+ default=None
2413
+ )
2414
+ if not settings:
2415
+ return 1, "Cannot read Claude settings"
1944
2416
 
1945
- # Check if any hcom hooks exist
1946
2417
  hooks_found = False
1947
2418
 
1948
- original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
1949
- for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2419
+ original_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2420
+ for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
1950
2421
 
1951
2422
  _remove_hcom_hooks_from_settings(settings)
1952
2423
 
1953
2424
  # Check if any were removed
1954
- new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
1955
- for event in ['PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
2425
+ new_hook_count = sum(len(settings.get('hooks', {}).get(event, []))
2426
+ for event in ['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'Notification'])
1956
2427
  if new_hook_count < original_hook_count:
1957
2428
  hooks_found = True
1958
2429
 
@@ -1977,56 +2448,49 @@ def cleanup_directory_hooks(directory):
1977
2448
 
1978
2449
  def cmd_kill(*args):
1979
2450
  """Kill instances by name or all with --all"""
1980
- import signal
1981
-
2451
+
1982
2452
  instance_name = args[0] if args and args[0] != '--all' else None
1983
2453
  positions = load_all_positions() if not instance_name else {instance_name: load_instance_position(instance_name)}
1984
-
2454
+
1985
2455
  # Filter to instances with PIDs (any instance that's running)
1986
2456
  targets = [(name, data) for name, data in positions.items() if data.get('pid')]
1987
-
2457
+
1988
2458
  if not targets:
1989
2459
  print(f"No running process found for {instance_name}" if instance_name else "No running instances found")
1990
2460
  return 1 if instance_name else 0
1991
-
2461
+
1992
2462
  killed_count = 0
1993
2463
  for target_name, target_data in targets:
1994
- # Get status and type info before killing
1995
2464
  status, age = get_instance_status(target_data)
1996
2465
  instance_type = "background" if target_data.get('background') else "foreground"
1997
-
2466
+
1998
2467
  pid = int(target_data['pid'])
1999
2468
  try:
2000
- # Send SIGTERM for graceful shutdown
2001
- os.kill(pid, signal.SIGTERM)
2002
-
2003
- # Wait up to 2 seconds for process to terminate
2469
+ # Try graceful termination first
2470
+ terminate_process(pid, force=False)
2471
+
2472
+ # Wait for process to exit gracefully
2004
2473
  for _ in range(20):
2005
- time.sleep(0.1)
2006
- try:
2007
- os.kill(pid, 0) # Check if process still exists
2008
- except ProcessLookupError:
2474
+ time.sleep(KILL_CHECK_INTERVAL)
2475
+ if not is_process_alive(pid):
2009
2476
  # Process terminated successfully
2010
2477
  break
2011
2478
  else:
2012
- # Process didn't die from SIGTERM, force kill
2013
- try:
2014
- os.kill(pid, signal.SIGKILL)
2015
- time.sleep(0.1) # Brief wait for SIGKILL to take effect
2016
- except ProcessLookupError:
2017
- pass # Already dead
2018
-
2479
+ # Process didn't die from graceful attempt, force kill
2480
+ terminate_process(pid, force=True)
2481
+ time.sleep(0.1)
2482
+
2019
2483
  print(f"Killed {target_name} ({instance_type}, {status}{age}, PID {pid})")
2020
2484
  killed_count += 1
2021
- except (ProcessLookupError, TypeError, ValueError) as e:
2022
- print(f"Process {pid} already dead or invalid: {e}")
2023
-
2485
+ except (TypeError, ValueError) as e:
2486
+ print(f"Process {pid} invalid: {e}")
2487
+
2024
2488
  # Mark instance as killed
2025
2489
  update_instance_position(target_name, {'pid': None})
2026
-
2490
+
2027
2491
  if not instance_name:
2028
2492
  print(f"Killed {killed_count} instance(s)")
2029
-
2493
+
2030
2494
  return 0
2031
2495
 
2032
2496
  def cmd_cleanup(*args):
@@ -2042,7 +2506,7 @@ def cmd_cleanup(*args):
2042
2506
  if isinstance(instance_data, dict) and 'directory' in instance_data:
2043
2507
  directories.add(instance_data['directory'])
2044
2508
  except Exception as e:
2045
- print(format_warning(f"Could not read current instances: {e}"))
2509
+ print(f"Warning: Could not read current instances: {e}")
2046
2510
 
2047
2511
  if not directories:
2048
2512
  print("No directories found in current hcom tracking")
@@ -2060,17 +2524,10 @@ def cmd_cleanup(*args):
2060
2524
  continue
2061
2525
 
2062
2526
  print(f"\nChecking {directory}...")
2063
-
2064
- # Check if settings file exists
2065
- settings_path = Path(directory) / '.claude' / 'settings.local.json'
2066
- if not settings_path.exists():
2067
- print(" No Claude settings found")
2068
- already_clean += 1
2069
- continue
2070
-
2527
+
2071
2528
  exit_code, message = cleanup_directory_hooks(Path(directory))
2072
2529
  if exit_code == 0:
2073
- if "No hcom hooks found" in message:
2530
+ if "No hcom hooks found" in message or "No Claude settings found" in message:
2074
2531
  already_clean += 1
2075
2532
  print(f" {message}")
2076
2533
  else:
@@ -2096,8 +2553,8 @@ def cmd_cleanup(*args):
2096
2553
  def cmd_send(message):
2097
2554
  """Send message to hcom"""
2098
2555
  # Check if hcom files exist
2099
- log_file = get_hcom_dir() / 'hcom.log'
2100
- instances_dir = get_hcom_dir() / 'instances'
2556
+ log_file = hcom_path(LOG_FILE)
2557
+ instances_dir = hcom_path(INSTANCES_DIR)
2101
2558
 
2102
2559
  if not log_file.exists() and not instances_dir.exists():
2103
2560
  print(format_error("No conversation found", "Run 'hcom open' first"), file=sys.stderr)
@@ -2106,7 +2563,7 @@ def cmd_send(message):
2106
2563
  # Validate message
2107
2564
  error = validate_message(message)
2108
2565
  if error:
2109
- print(f"Error: {error}", file=sys.stderr)
2566
+ print(error, file=sys.stderr)
2110
2567
  return 1
2111
2568
 
2112
2569
  # Check for unmatched mentions (minimal warning)
@@ -2139,12 +2596,6 @@ def cmd_send(message):
2139
2596
 
2140
2597
  # ==================== Hook Functions ====================
2141
2598
 
2142
- def update_instance_with_pid(updates, existing_data):
2143
- """Add PID to updates if not already set in existing data"""
2144
- if not existing_data.get('pid'):
2145
- updates['pid'] = os.getppid() # Parent PID is the Claude process
2146
- return updates
2147
-
2148
2599
  def format_hook_messages(messages, instance_name):
2149
2600
  """Format messages for hook feedback"""
2150
2601
  if len(messages) == 1:
@@ -2153,305 +2604,509 @@ def format_hook_messages(messages, instance_name):
2153
2604
  else:
2154
2605
  parts = [f"{msg['from']} → {instance_name}: {msg['message']}" for msg in messages]
2155
2606
  reason = f"[{len(messages)} new messages] | " + " | ".join(parts)
2156
-
2607
+
2608
+ # Check alias announcement
2609
+ instance_data = load_instance_position(instance_name)
2610
+ if not instance_data.get('alias_announced', False) and not instance_name.endswith('claude'):
2611
+ 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)>"
2612
+ update_instance_position(instance_name, {'alias_announced': True})
2613
+
2157
2614
  # Only append instance_hints to messages (first_use_text is handled separately)
2158
2615
  instance_hints = get_config_value('instance_hints', '')
2159
2616
  if instance_hints:
2160
2617
  reason = f"{reason} | [{instance_hints}]"
2161
-
2618
+
2162
2619
  return reason
2163
2620
 
2164
- def handle_hook_post():
2165
- """Handle PostToolUse hook"""
2166
- # Check if active
2167
- if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
2168
- sys.exit(EXIT_SUCCESS)
2169
-
2621
+ def get_pending_tools(transcript_path, max_lines=100):
2622
+ """Parse transcript to find tool_use IDs without matching tool_results.
2623
+ Returns count of pending tools."""
2624
+ if not transcript_path or not os.path.exists(transcript_path):
2625
+ return 0
2626
+
2627
+ tool_uses = set()
2628
+ tool_results = set()
2629
+
2170
2630
  try:
2171
- # Read JSON input
2172
- hook_data = json.load(sys.stdin)
2173
- transcript_path = hook_data.get('transcript_path', '')
2174
- instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
2175
- conversation_uuid = get_conversation_uuid(transcript_path)
2176
-
2177
- # Migrate instance name if needed (from fallback to UUID-based)
2178
- instance_name = migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path)
2179
-
2180
- initialize_instance_in_position_file(instance_name, conversation_uuid)
2181
-
2182
- # Get existing position data to check if already set
2183
- existing_data = load_instance_position(instance_name)
2184
-
2185
- updates = {
2186
- 'last_tool': int(time.time()),
2187
- 'last_tool_name': hook_data.get('tool_name', 'unknown'),
2188
- 'session_id': hook_data.get('session_id', ''),
2189
- 'transcript_path': transcript_path,
2190
- 'conversation_uuid': conversation_uuid or 'unknown',
2191
- 'directory': str(Path.cwd()),
2192
- }
2193
-
2194
- # Update PID if not already set
2195
- update_instance_with_pid(updates, existing_data)
2196
-
2197
- # Check if this is a background instance and save log file
2198
- bg_env = os.environ.get('HCOM_BACKGROUND')
2199
- if bg_env:
2200
- updates['background'] = True
2201
- updates['background_log_file'] = str(get_hcom_dir() / 'logs' / bg_env)
2202
-
2203
- update_instance_position(instance_name, updates)
2204
-
2205
- # Check for first-use help (prepend to first activity)
2206
- welcome_text = get_first_use_text(instance_name)
2207
- welcome_prefix = f"{welcome_text} | " if welcome_text else ""
2208
-
2209
- # Check for HCOM_SEND in Bash commands
2210
- sent_reason = None
2211
- if hook_data.get('tool_name') == 'Bash':
2212
- command = hook_data.get('tool_input', {}).get('command', '')
2213
- if 'HCOM_SEND:' in command:
2214
- # Extract message after HCOM_SEND:
2215
- parts = command.split('HCOM_SEND:', 1)
2216
- if len(parts) > 1:
2217
- remainder = parts[1]
2218
-
2219
- # The message might be in the format:
2220
- # - message" (from echo "HCOM_SEND:message")
2221
- # - message' (from echo 'HCOM_SEND:message')
2222
- # - message (from echo HCOM_SEND:message)
2223
- # - "message" (from echo HCOM_SEND:"message")
2224
-
2225
- message = remainder.strip()
2226
-
2227
- # If it starts and ends with matching quotes, remove them
2228
- if len(message) >= 2 and \
2229
- ((message[0] == '"' and message[-1] == '"') or \
2230
- (message[0] == "'" and message[-1] == "'")):
2231
- message = message[1:-1]
2232
- # If it ends with a quote but doesn't start with one,
2233
- # it's likely from echo "HCOM_SEND:message" format
2234
- elif message and message[-1] in '"\'':
2235
- message = message[:-1]
2236
-
2237
- if message:
2238
- error = validate_message(message)
2239
- if error:
2240
- output = {"reason": f"❌ {error}"}
2241
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2242
- sys.exit(EXIT_BLOCK)
2243
-
2244
- send_message(instance_name, message)
2245
- sent_reason = "[✓ Sent]"
2246
-
2631
+ # Read last N lines efficiently
2632
+ with open(transcript_path, 'rb') as f:
2633
+ # Seek to end and read backwards
2634
+ f.seek(0, 2) # Go to end
2635
+ file_size = f.tell()
2636
+ read_size = min(file_size, max_lines * 500) # Assume ~500 bytes per line
2637
+ f.seek(max(0, file_size - read_size))
2638
+ recent_content = f.read().decode('utf-8', errors='ignore')
2639
+
2640
+ # Parse line by line (handle both Unix \n and Windows \r\n)
2641
+ for line in recent_content.splitlines():
2642
+ if not line.strip():
2643
+ continue
2644
+ try:
2645
+ data = json.loads(line)
2646
+
2647
+ # Check for tool_use blocks in assistant messages
2648
+ if data.get('type') == 'assistant':
2649
+ content = data.get('message', {}).get('content', [])
2650
+ if isinstance(content, list):
2651
+ for item in content:
2652
+ if isinstance(item, dict) and item.get('type') == 'tool_use':
2653
+ tool_id = item.get('id')
2654
+ if tool_id:
2655
+ tool_uses.add(tool_id)
2656
+
2657
+ # Check for tool_results in user messages
2658
+ elif data.get('type') == 'user':
2659
+ content = data.get('message', {}).get('content', [])
2660
+ if isinstance(content, list):
2661
+ for item in content:
2662
+ if isinstance(item, dict) and item.get('type') == 'tool_result':
2663
+ tool_id = item.get('tool_use_id')
2664
+ if tool_id:
2665
+ tool_results.add(tool_id)
2666
+ except Exception as e:
2667
+ continue
2668
+
2669
+ # Return count of pending tools
2670
+ pending = tool_uses - tool_results
2671
+ return len(pending)
2672
+ except Exception as e:
2673
+ return 0 # On any error, assume no pending tools
2674
+
2675
+ # ==================== Hook Handlers ====================
2676
+
2677
+ def init_hook_context(hook_data):
2678
+ """Initialize instance context - shared by post/stop/notify hooks"""
2679
+ session_id = hook_data.get('session_id', '')
2680
+ transcript_path = hook_data.get('transcript_path', '')
2681
+ prefix = os.environ.get('HCOM_PREFIX')
2682
+
2683
+ # Check if this is a resume operation
2684
+ resume_session_id = os.environ.get('HCOM_RESUME_SESSION_ID')
2685
+ instances_dir = hcom_path(INSTANCES_DIR)
2686
+ instance_name = None
2687
+ merged_state = None
2688
+
2689
+ # First, try to find existing instance by resume sessionId
2690
+ if resume_session_id and instances_dir.exists():
2691
+ for instance_file in instances_dir.glob("*.json"):
2692
+ try:
2693
+ data = load_instance_position(instance_file.stem)
2694
+ # Check if resume_session_id matches any in the session_ids array
2695
+ old_session_ids = data.get('session_ids', [])
2696
+ if resume_session_id in old_session_ids:
2697
+ # Found the instance! Keep the same name
2698
+ instance_name = instance_file.stem
2699
+ merged_state = data
2700
+ # Append new session_id to array, update transcript_path to current
2701
+ if session_id and session_id not in old_session_ids:
2702
+ merged_state.setdefault('session_ids', old_session_ids).append(session_id)
2703
+ if transcript_path:
2704
+ merged_state['transcript_path'] = transcript_path
2705
+ break
2706
+ except:
2707
+ continue
2708
+
2709
+ # Check if current session exists in any instance's session_ids array
2710
+ # This maintains identity after implicit HCOM_RESUME
2711
+ if not instance_name and session_id and instances_dir.exists():
2712
+ for instance_file in instances_dir.glob("*.json"):
2713
+ try:
2714
+ data = load_instance_position(instance_file.stem)
2715
+ if session_id in data.get('session_ids', []):
2716
+ instance_name = instance_file.stem
2717
+ merged_state = data
2718
+ break
2719
+ except:
2720
+ continue
2721
+
2722
+ # If not found or not resuming, generate new name from session_id
2723
+ if not instance_name:
2724
+ instance_name = get_display_name(session_id, prefix)
2725
+
2726
+ # PID deduplication: Clean up any stale instance files with same PID
2727
+ # Always run to clean up temp instances even after implicit resume
2728
+ parent_pid = os.getppid()
2729
+ if instances_dir.exists():
2730
+ for instance_file in instances_dir.glob("*.json"):
2731
+ if instance_file.stem != instance_name: # Skip current instance
2732
+ try:
2733
+ data = load_instance_position(instance_file.stem)
2734
+ if data.get('pid') == parent_pid:
2735
+ # Found duplicate with same PID - merge and delete
2736
+ if not merged_state:
2737
+ merged_state = data
2738
+ else:
2739
+ # Merge useful fields from duplicate
2740
+ merged_state = merge_instance_data(merged_state, data)
2741
+ instance_file.unlink() # Delete the duplicate file
2742
+ # Don't break - could have multiple duplicates with same PID
2743
+ except:
2744
+ continue
2745
+
2746
+ # Save migrated data if we have it
2747
+ if merged_state:
2748
+ save_instance_position(instance_name, merged_state)
2749
+
2750
+ initialize_instance_in_position_file(instance_name, session_id)
2751
+ existing_data = load_instance_position(instance_name)
2752
+
2753
+ # Prepare updates - use array for session_ids, single field for transcript_path
2754
+ updates = {
2755
+ 'directory': str(Path.cwd()),
2756
+ }
2757
+
2758
+ # Update session_ids array if we have a new session_id
2759
+ if session_id:
2760
+ current_session_ids = existing_data.get('session_ids', [])
2761
+ if session_id not in current_session_ids:
2762
+ current_session_ids.append(session_id)
2763
+ updates['session_ids'] = current_session_ids
2764
+
2765
+ # Update transcript_path to current
2766
+ if transcript_path:
2767
+ updates['transcript_path'] = transcript_path
2768
+
2769
+ # Always update PID to current (fixes stale PID on implicit resume)
2770
+ updates['pid'] = os.getppid()
2771
+
2772
+ # Add background status if applicable
2773
+ bg_env = os.environ.get('HCOM_BACKGROUND')
2774
+ if bg_env:
2775
+ updates['background'] = True
2776
+ updates['background_log_file'] = str(hcom_path(LOGS_DIR, bg_env))
2777
+
2778
+ return instance_name, updates, existing_data
2779
+
2780
+ def extract_hcom_command(command, prefix='HCOM_SEND'):
2781
+ """Extract command payload with quote stripping"""
2782
+ marker = f'{prefix}:'
2783
+ if marker not in command:
2784
+ return None
2785
+
2786
+ parts = command.split(marker, 1)
2787
+ if len(parts) <= 1:
2788
+ return None
2789
+
2790
+ payload = parts[1].strip()
2791
+
2792
+ # Complex quote stripping logic (preserves exact behavior)
2793
+ if len(payload) >= 2 and \
2794
+ ((payload[0] == '"' and payload[-1] == '"') or \
2795
+ (payload[0] == "'" and payload[-1] == "'")):
2796
+ payload = payload[1:-1]
2797
+ elif payload and payload[-1] in '"\'':
2798
+ payload = payload[:-1]
2799
+
2800
+ return payload if payload else None
2801
+
2802
+ def _sanitize_alias(alias):
2803
+ """Sanitize extracted alias: strip quotes/backticks, stop at first invalid char/whitespace."""
2804
+ alias = alias.strip()
2805
+ # Strip wrapping quotes/backticks iteratively
2806
+ for _ in range(3):
2807
+ if len(alias) >= 2 and alias[0] == alias[-1] and alias[0] in ['"', "'", '`']:
2808
+ alias = alias[1:-1].strip()
2809
+ elif alias and alias[-1] in ['"', "'", '`']:
2810
+ alias = alias[:-1].strip()
2811
+ else:
2812
+ break
2813
+ # Stop at first whitespace or invalid char
2814
+ alias = re.split(r'[^A-Za-z0-9\-_]', alias)[0]
2815
+ return alias
2816
+
2817
+ def extract_resume_alias(command):
2818
+ """Extract resume alias safely.
2819
+ Priority:
2820
+ 1) HCOM_SEND payload that starts with RESUME:alias
2821
+ 2) Bare HCOM_RESUME:alias (only when not embedded in HCOM_SEND payload)
2822
+ """
2823
+ # 1) Prefer explicit HCOM_SEND payload
2824
+ payload = extract_hcom_command(command)
2825
+ if payload:
2826
+ cand = payload.strip()
2827
+ if cand.startswith('RESUME:'):
2828
+ alias_raw = cand.split(':', 1)[1].strip()
2829
+ alias = _sanitize_alias(alias_raw)
2830
+ return alias or None
2831
+ # If payload contains text like "HCOM_RESUME:alias" but not at start,
2832
+ # ignore to prevent alias hijack from normal messages
2833
+
2834
+ # 2) Fallback: bare HCOM_RESUME when not using HCOM_SEND
2835
+ alias_raw = extract_hcom_command(command, 'HCOM_RESUME')
2836
+ if alias_raw:
2837
+ alias = _sanitize_alias(alias_raw)
2838
+ return alias or None
2839
+ return None
2840
+
2841
+ def compute_decision_for_visibility(transcript_path):
2842
+ """Compute hook decision based on pending tools to prevent API 400 errors."""
2843
+ pending_tools = get_pending_tools(transcript_path)
2844
+ decision = None if pending_tools > 0 else HOOK_DECISION_BLOCK
2845
+
2846
+ return decision
2847
+
2848
+ def emit_resume_feedback(status, instance_name, transcript_path):
2849
+ """Emit formatted resume feedback with appropriate visibility."""
2850
+ # Build formatted feedback based on success/failure
2851
+ if status.startswith("[SUCCESS]"):
2852
+ reason = f"[{status}]{HCOM_FORMAT_INSTRUCTIONS}"
2853
+ else:
2854
+ reason = f"[⚠️ {status} - your alias is: {instance_name}]{HCOM_FORMAT_INSTRUCTIONS}"
2855
+
2856
+ # Compute decision based on pending tools
2857
+ decision = compute_decision_for_visibility(transcript_path)
2858
+
2859
+ # Emit response
2860
+ emit_hook_response(reason, decision=decision)
2861
+
2862
+ def handle_pretooluse(hook_data):
2863
+ """Handle PreToolUse hook - auto-approve HCOM_SEND commands when safe"""
2864
+ # Check if this is an HCOM_SEND command that needs auto-approval
2865
+ tool_name = hook_data.get('tool_name', '')
2866
+ if tool_name == 'Bash':
2867
+ command = hook_data.get('tool_input', {}).get('command', '')
2868
+ if 'HCOM_SEND:' in command or extract_resume_alias(command):
2869
+ # Check if other tools are pending - prevent API 400 errors
2870
+ transcript_path = hook_data.get('transcript_path', '')
2871
+ # Subtract 1 because the current tool is already in transcript but not actually pending
2872
+ pending_count = max(0, get_pending_tools(transcript_path) - 1)
2873
+
2874
+ if pending_count > 0:
2875
+ # Deny execution to prevent injecting content between tool_use/tool_result
2876
+ output = {
2877
+ "hookSpecificOutput": {
2878
+ "hookEventName": "PreToolUse",
2879
+ "permissionDecision": "deny",
2880
+ "permissionDecisionReason": f"Waiting - {pending_count} tool(s) still executing. Try again in a moment."
2881
+ }
2882
+ }
2883
+ else:
2884
+ # Safe to proceed
2885
+ output = {
2886
+ "hookSpecificOutput": {
2887
+ "hookEventName": "PreToolUse",
2888
+ "permissionDecision": "allow",
2889
+ "permissionDecisionReason": "HCOM_SEND command auto-approved"
2890
+ }
2891
+ }
2892
+ print(json.dumps(output, ensure_ascii=False))
2893
+ sys.exit(EXIT_SUCCESS)
2894
+
2895
+ def handle_posttooluse(hook_data, instance_name, updates):
2896
+ """Handle PostToolUse hook - extract and deliver messages"""
2897
+ updates['last_tool'] = int(time.time())
2898
+ updates['last_tool_name'] = hook_data.get('tool_name', 'unknown')
2899
+ update_instance_position(instance_name, updates)
2900
+
2901
+ # Check for HCOM_SEND in Bash commands
2902
+ sent_reason = None
2903
+ if hook_data.get('tool_name') == 'Bash':
2904
+ command = hook_data.get('tool_input', {}).get('command', '')
2905
+
2906
+ # Check for RESUME command first (safe extraction)
2907
+ alias = extract_resume_alias(command)
2908
+ if alias:
2909
+ status = merge_instance_immediately(instance_name, alias)
2910
+
2911
+ # If names match, find and merge any duplicate with same PID
2912
+ if not status and instance_name == alias:
2913
+ instances_dir = hcom_path(INSTANCES_DIR)
2914
+ parent_pid = os.getppid()
2915
+ if instances_dir.exists():
2916
+ for instance_file in instances_dir.glob("*.json"):
2917
+ if instance_file.stem != instance_name:
2918
+ try:
2919
+ data = load_instance_position(instance_file.stem)
2920
+ if data.get('pid') == parent_pid:
2921
+ # Found duplicate - merge it
2922
+ status = merge_instance_immediately(instance_file.stem, instance_name)
2923
+ if status:
2924
+ status = f"[SUCCESS] ✓ Merged duplicate: {instance_file.stem} → {instance_name}"
2925
+ break
2926
+ except:
2927
+ continue
2928
+
2929
+ if not status:
2930
+ status = f"[SUCCESS] ✓ Already using alias {alias}"
2931
+ elif not status:
2932
+ status = f"[WARNING] ⚠️ Merge failed: {instance_name} → {alias}"
2933
+
2934
+ if status:
2935
+ transcript_path = hook_data.get('transcript_path', '')
2936
+ emit_resume_feedback(status, instance_name, transcript_path)
2937
+ return # Don't process RESUME as regular message
2938
+
2939
+ # Normal message handling
2940
+ message = extract_hcom_command(command) # defaults to HCOM_SEND
2941
+ if message:
2942
+ error = validate_message(message)
2943
+ if error:
2944
+ emit_hook_response(f"❌ {error}")
2945
+ send_message(instance_name, message)
2946
+ sent_reason = "[✓ Sent]"
2947
+
2948
+ # Check for pending tools in transcript
2949
+ transcript_path = hook_data.get('transcript_path', '')
2950
+ pending_count = get_pending_tools(transcript_path)
2951
+
2952
+ # Build response if needed
2953
+ response_reason = None
2954
+
2955
+ # Only deliver messages when all tools are complete (pending_count == 0)
2956
+ if pending_count == 0:
2247
2957
  messages = get_new_messages(instance_name)
2248
-
2249
- # Limit messages to max_messages_per_delivery
2250
2958
  if messages:
2251
- max_messages = get_config_value('max_messages_per_delivery', 50)
2252
- messages = messages[:max_messages]
2253
-
2254
- if messages and sent_reason:
2255
- # Both sent and received
2256
- reason = f"{sent_reason} | {format_hook_messages(messages, instance_name)}"
2257
- if welcome_prefix:
2258
- reason = f"{welcome_prefix}{reason}"
2259
- output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
2260
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2261
- sys.exit(EXIT_BLOCK)
2262
- elif messages:
2263
- # Just received
2959
+ messages = messages[:get_config_value('max_messages_per_delivery', 50)]
2264
2960
  reason = format_hook_messages(messages, instance_name)
2265
- if welcome_prefix:
2266
- reason = f"{welcome_prefix}{reason}"
2267
- output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
2268
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2269
- sys.exit(EXIT_BLOCK)
2961
+ response_reason = f"{sent_reason} | {reason}" if sent_reason else reason
2270
2962
  elif sent_reason:
2271
- # Just sent
2272
- reason = sent_reason
2273
- if welcome_prefix:
2274
- reason = f"{welcome_prefix}{reason}"
2275
- output = {"reason": reason}
2276
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2277
- sys.exit(EXIT_BLOCK)
2278
- elif welcome_prefix:
2279
- # No activity but need to show welcome
2280
- output = {"decision": HOOK_DECISION_BLOCK, "reason": welcome_prefix.rstrip(' | ')}
2281
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2282
- sys.exit(EXIT_BLOCK)
2283
-
2284
- except Exception:
2963
+ response_reason = sent_reason
2964
+ elif sent_reason:
2965
+ # Tools still pending - acknowledge HCOM_SEND without disrupting tool batching
2966
+ response_reason = sent_reason
2967
+
2968
+ # Emit response with formatting if we have anything to say
2969
+ if response_reason:
2970
+ response_reason += HCOM_FORMAT_INSTRUCTIONS
2971
+ # CRITICAL: decision=None when tools are pending to prevent API 400 errors
2972
+ decision = compute_decision_for_visibility(transcript_path)
2973
+ emit_hook_response(response_reason, decision=decision)
2974
+
2975
+ def handle_stop(instance_name, updates):
2976
+ """Handle Stop hook - poll for messages"""
2977
+ updates['last_stop'] = time.time()
2978
+ timeout = get_config_value('wait_timeout', 1800)
2979
+ updates['wait_timeout'] = timeout
2980
+
2981
+ # Try to update position, but continue on Windows file locking errors
2982
+ try:
2983
+ update_instance_position(instance_name, updates)
2984
+ except Exception as e:
2985
+ # Silently handle initial file locking error and continue
2285
2986
  pass
2286
-
2287
- sys.exit(EXIT_SUCCESS)
2288
2987
 
2289
- def handle_hook_stop():
2290
- """Handle Stop hook"""
2291
- # Check if active
2292
- if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
2293
- sys.exit(EXIT_SUCCESS)
2294
-
2988
+ parent_pid = os.getppid()
2989
+ start_time = time.time()
2990
+
2295
2991
  try:
2296
- # Read hook input
2297
- hook_data = json.load(sys.stdin)
2298
- transcript_path = hook_data.get('transcript_path', '')
2299
- instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
2300
- conversation_uuid = get_conversation_uuid(transcript_path)
2301
-
2302
- # Migrate instance name if needed (from fallback to UUID-based)
2303
- instance_name = migrate_instance_name_if_needed(instance_name, conversation_uuid, transcript_path)
2304
-
2305
- # Initialize instance if needed
2306
- initialize_instance_in_position_file(instance_name, conversation_uuid)
2307
-
2308
- # Get the timeout this hook will use
2309
- timeout = get_config_value('wait_timeout', 1800)
2310
-
2311
- # Get existing position data to check if PID is already set
2312
- existing_data = load_instance_position(instance_name)
2313
-
2314
- # Update instance as waiting and store the timeout
2315
- updates = {
2316
- 'last_stop': time.time(),
2317
- 'wait_timeout': timeout, # Store this instance's timeout
2318
- 'session_id': hook_data.get('session_id', ''),
2319
- 'transcript_path': transcript_path,
2320
- 'conversation_uuid': conversation_uuid or 'unknown',
2321
- 'directory': str(Path.cwd()),
2322
- }
2323
-
2324
- # Update PID if not already set
2325
- update_instance_with_pid(updates, existing_data)
2326
-
2327
- # Check if this is a background instance and save log file
2328
- bg_env = os.environ.get('HCOM_BACKGROUND')
2329
- if bg_env:
2330
- updates['background'] = True
2331
- updates['background_log_file'] = str(get_hcom_dir() / 'logs' / bg_env)
2332
-
2333
- update_instance_position(instance_name, updates)
2334
-
2335
- parent_pid = os.getppid()
2336
-
2337
- # Check for first-use help
2338
- check_and_show_first_use_help(instance_name)
2339
-
2340
- # Simple polling loop with parent check
2341
- start_time = time.time()
2342
-
2992
+ loop_count = 0
2343
2993
  while time.time() - start_time < timeout:
2344
- # Check if parent is alive
2345
- if not is_parent_alive(parent_pid):
2994
+ loop_count += 1
2995
+ current_time = time.time()
2996
+
2997
+ # Unix/Mac: Check if orphaned (reparented to PID 1)
2998
+ if not IS_WINDOWS and os.getppid() == 1:
2346
2999
  sys.exit(EXIT_SUCCESS)
2347
-
2348
- # Check for new messages
2349
- messages = get_new_messages(instance_name)
2350
-
2351
- if messages:
2352
- # Deliver messages
2353
- max_messages = get_config_value('max_messages_per_delivery', 50)
2354
- messages_to_show = messages[:max_messages]
2355
-
2356
- reason = format_hook_messages(messages_to_show, instance_name)
2357
- output = {"decision": HOOK_DECISION_BLOCK, "reason": reason}
2358
- print(json.dumps(output, ensure_ascii=False), file=sys.stderr)
2359
- sys.exit(EXIT_BLOCK)
2360
-
2361
- # Update heartbeat
2362
- update_instance_position(instance_name, {
2363
- 'last_stop': time.time()
2364
- })
2365
-
2366
- time.sleep(0.1)
2367
-
2368
- except Exception:
2369
- pass
2370
-
2371
- sys.exit(EXIT_SUCCESS)
2372
3000
 
2373
- def handle_hook_notification():
2374
- """Handle Notification hook"""
2375
- # Check if active
2376
- if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
3001
+ # All platforms: Check if parent is alive
3002
+ parent_alive = is_parent_alive(parent_pid)
3003
+
3004
+ if not parent_alive:
3005
+ sys.exit(EXIT_SUCCESS)
3006
+
3007
+ # Check for pending tools before delivering messages
3008
+ transcript_path = updates.get('transcript_path', '')
3009
+ pending_count = get_pending_tools(transcript_path)
3010
+
3011
+ # Only deliver messages when no tools are pending
3012
+ if pending_count == 0:
3013
+ messages = get_new_messages(instance_name)
3014
+ if messages:
3015
+ messages_to_show = messages[:get_config_value('max_messages_per_delivery', 50)]
3016
+ reason = format_hook_messages(messages_to_show, instance_name)
3017
+ emit_hook_response(reason) # Normal visible delivery
3018
+
3019
+ # Update position to keep instance marked as alive
3020
+ stop_update_time = time.time()
3021
+ try:
3022
+ update_instance_position(instance_name, {'last_stop': stop_update_time})
3023
+ except Exception as e:
3024
+ # Silently handle file locking exceptions on Windows and continue polling
3025
+ pass
3026
+
3027
+ time.sleep(STOP_HOOK_POLL_INTERVAL)
3028
+
3029
+ except Exception as e:
3030
+ # Exit with code 0 on unexpected exceptions (fail safe)
2377
3031
  sys.exit(EXIT_SUCCESS)
2378
-
2379
- try:
2380
- # Read hook input
2381
- hook_data = json.load(sys.stdin)
2382
- transcript_path = hook_data.get('transcript_path', '')
2383
- instance_name = get_display_name(transcript_path) if transcript_path else f"{Path.cwd().name[:2].lower()}claude"
2384
- conversation_uuid = get_conversation_uuid(transcript_path)
2385
-
2386
- # Initialize instance if needed
2387
- initialize_instance_in_position_file(instance_name, conversation_uuid)
2388
-
2389
- # Get existing position data to check if PID is already set
2390
- existing_data = load_instance_position(instance_name)
2391
-
2392
- # Update permission request timestamp
2393
- updates = {
2394
- 'last_permission_request': int(time.time()),
2395
- 'notification_message': hook_data.get('message', ''),
2396
- 'session_id': hook_data.get('session_id', ''),
2397
- 'transcript_path': transcript_path,
2398
- 'conversation_uuid': conversation_uuid or 'unknown',
2399
- 'directory': str(Path.cwd()),
3032
+
3033
+ def handle_notify(hook_data, instance_name, updates):
3034
+ """Handle Notification hook - track permission requests"""
3035
+ updates['last_permission_request'] = int(time.time())
3036
+ updates['notification_message'] = hook_data.get('message', '')
3037
+ update_instance_position(instance_name, updates)
3038
+
3039
+ def handle_sessionstart(hook_data, instance_name, updates):
3040
+ """Handle SessionStart hook - deliver welcome/resume message"""
3041
+ source = hook_data.get('source', 'startup')
3042
+
3043
+ # Reset alias_announced flag so alias shows again on resume/clear/compact
3044
+ updates['alias_announced'] = False
3045
+
3046
+ # Always show base help text
3047
+ help_text = "[Welcome! HCOM chat active. Send messages: echo 'HCOM_SEND:your message']"
3048
+
3049
+ # Add first use text only on startup
3050
+ if source == 'startup':
3051
+ first_use_text = get_config_value('first_use_text', '')
3052
+ if first_use_text:
3053
+ help_text += f" [{first_use_text}]"
3054
+ elif source == 'resume':
3055
+ if not os.environ.get('HCOM_RESUME_SESSION_ID'):
3056
+ # Implicit resume - prompt for alias recovery
3057
+ help_text += f" [⚠️ Resume detected - temp: {instance_name}. If you had a previous HCOM alias, run: echo \"HCOM_RESUME:your_alias\"]"
3058
+ else:
3059
+ help_text += " [Resuming session - you should have the same hcom alias as before]"
3060
+
3061
+ # Add instance hints to all messages
3062
+ instance_hints = get_config_value('instance_hints', '')
3063
+ if instance_hints:
3064
+ help_text += f" [{instance_hints}]"
3065
+
3066
+ # Output as additionalContext using hookSpecificOutput format
3067
+ output = {
3068
+ "hookSpecificOutput": {
3069
+ "hookEventName": "SessionStart",
3070
+ "additionalContext": help_text
2400
3071
  }
2401
-
2402
- # Update PID if not already set
2403
- update_instance_with_pid(updates, existing_data)
2404
-
2405
- # Check if this is a background instance and save log file
2406
- bg_env = os.environ.get('HCOM_BACKGROUND')
2407
- if bg_env:
2408
- updates['background'] = True
2409
- updates['background_log_file'] = str(get_hcom_dir() / 'logs' / bg_env)
2410
-
2411
- update_instance_position(instance_name, updates)
2412
-
2413
- check_and_show_first_use_help(instance_name)
2414
-
2415
- except Exception:
2416
- pass
2417
-
2418
- sys.exit(EXIT_SUCCESS)
3072
+ }
3073
+ print(json.dumps(output))
2419
3074
 
2420
- def handle_hook_pretooluse():
2421
- """Handle PreToolUse hook - auto-approve HCOM_SEND commands"""
2422
- # Check if active
3075
+ # Update instance position
3076
+ update_instance_position(instance_name, updates)
3077
+
3078
+ def handle_hook(hook_type):
3079
+ """Unified hook handler for all HCOM hooks"""
2423
3080
  if os.environ.get(HCOM_ACTIVE_ENV) != HCOM_ACTIVE_VALUE:
2424
3081
  sys.exit(EXIT_SUCCESS)
2425
-
3082
+
2426
3083
  try:
2427
- # Read hook input
2428
3084
  hook_data = json.load(sys.stdin)
2429
- tool_name = hook_data.get('tool_name', '')
2430
- tool_input = hook_data.get('tool_input', {})
2431
-
2432
- # Only process Bash commands
2433
- if tool_name == 'Bash':
2434
- command = tool_input.get('command', '')
2435
-
2436
- # Check if it's an HCOM_SEND command
2437
- if 'HCOM_SEND:' in command:
2438
- # Auto-approve HCOM_SEND commands
2439
- output = {
2440
- "hookSpecificOutput": {
2441
- "hookEventName": "PreToolUse",
2442
- "permissionDecision": "allow",
2443
- "permissionDecisionReason": "HCOM_SEND command auto-approved"
2444
- }
2445
- }
2446
- print(json.dumps(output, ensure_ascii=False))
2447
- sys.exit(EXIT_SUCCESS)
2448
-
3085
+
3086
+ # Route to specific handler with only needed parameters
3087
+ if hook_type == 'pre':
3088
+ # PreToolUse only needs hook_data
3089
+ handle_pretooluse(hook_data)
3090
+ else:
3091
+ # Other hooks need context initialization
3092
+ instance_name, updates, _ = init_hook_context(hook_data)
3093
+
3094
+ if hook_type == 'post':
3095
+ handle_posttooluse(hook_data, instance_name, updates)
3096
+ elif hook_type == 'stop':
3097
+ # Stop hook doesn't use hook_data
3098
+ handle_stop(instance_name, updates)
3099
+ elif hook_type == 'notify':
3100
+ handle_notify(hook_data, instance_name, updates)
3101
+ elif hook_type == 'sessionstart':
3102
+ handle_sessionstart(hook_data, instance_name, updates)
3103
+
2449
3104
  except Exception:
2450
3105
  pass
2451
-
2452
- # Let normal permission flow continue for non-HCOM_SEND commands
3106
+
2453
3107
  sys.exit(EXIT_SUCCESS)
2454
3108
 
3109
+
2455
3110
  # ==================== Main Entry Point ====================
2456
3111
 
2457
3112
  def main(argv=None):
@@ -2484,19 +3139,9 @@ def main(argv=None):
2484
3139
  return cmd_kill(*argv[2:])
2485
3140
 
2486
3141
  # Hook commands
2487
- elif cmd == 'post':
2488
- handle_hook_post()
2489
- return 0
2490
- elif cmd == 'stop':
2491
- handle_hook_stop()
3142
+ elif cmd in ['post', 'stop', 'notify', 'pre', 'sessionstart']:
3143
+ handle_hook(cmd)
2492
3144
  return 0
2493
- elif cmd == 'notify':
2494
- handle_hook_notification()
2495
- return 0
2496
- elif cmd == 'pre':
2497
- handle_hook_pretooluse()
2498
- return 0
2499
-
2500
3145
 
2501
3146
  # Unknown command
2502
3147
  else: