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