hcom 0.1.8__py3-none-any.whl → 0.2.1__py3-none-any.whl

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

Potentially problematic release.


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

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