netra-zen 1.0.8__py3-none-any.whl → 1.0.10__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netra-zen
3
- Version: 1.0.8
3
+ Version: 1.0.10
4
4
  Summary: Multi-instance Claude orchestrator for parallel task execution
5
5
  Home-page: https://github.com/netra-systems/zen
6
6
  Author: Systems
@@ -1,10 +1,10 @@
1
1
  zen_orchestrator.py,sha256=JAxmSaXsF9xF7sVGHmjtEfWBMgomsO-vuJ2RsZ0Paiw,151118
2
2
  agent_interface/__init__.py,sha256=OsbOKzElHsxhVgak87oOx_u46QNgKmz-Reis-plAMwk,525
3
3
  agent_interface/base_agent.py,sha256=GNskG9VaZgno7X24lQTpFdxUoQE0yJHLh0UPFJvOPn4,11098
4
- netra_zen-1.0.8.dist-info/licenses/LICENSE.md,sha256=t6LtOzAE2hgIIv5WbaN0wOcU3QCnGtAkMGNclHrKTOs,79
4
+ netra_zen-1.0.10.dist-info/licenses/LICENSE.md,sha256=t6LtOzAE2hgIIv5WbaN0wOcU3QCnGtAkMGNclHrKTOs,79
5
5
  scripts/__init__.py,sha256=FxMRmQuf7CAoQFpNqJcugEqDoi-hSpq9IwjxCmC6Ays,51
6
6
  scripts/__main__.py,sha256=41cdZ5GkvQ7ndWYUVJ6BnBi6haaa6SRQmBaYjUzOW3g,155
7
- scripts/agent_cli.py,sha256=c9qmAx1Fk2XNidveD2zhpF7ndj5hwgVwUcAEGY5kjJM,295129
7
+ scripts/agent_cli.py,sha256=pz3tdk6kOp064jf72EFKO9QVhuIs0ZZMdrggojdKt0U,320550
8
8
  scripts/agent_logs.py,sha256=AzSPA9nCvh2toC6pa5mzv4l3F-jTOHUg-G8NQRbguAo,10830
9
9
  scripts/bump_version.py,sha256=fjABzzRVXJ00CbYMpUIUMwcOHwafLYtFL6NvUga-i6M,4183
10
10
  scripts/demo_log_collection.py,sha256=8T7qfgiYc33apBtu2ATN2DDYZtzt_HM7D2PCNf8TcfI,5274
@@ -19,10 +19,10 @@ token_transparency/claude_pricing_engine.py,sha256=9zWQJS3HJEs_lljil-xT1cUvG-Jf3
19
19
  zen/__init__.py,sha256=_1gd3iYHH2ekLrCvZQ4DEA2bZ-OK0vlLfxBb3KlZALU,206
20
20
  zen/__main__.py,sha256=zoXi9DiNt_WznQvnJ249ZvF-OcEoAnHmxeoKRFiPNo8,170
21
21
  zen/telemetry/__init__.py,sha256=QiW8p9TBDwPxtmYTszMyccblLHKrlVTsKLFIBvMHKx8,305
22
- zen/telemetry/embedded_credentials.py,sha256=K6i9LOGnBx6DXpaVBKmZYpvJ9LIYlAjQVXyjiKIF9x0,1657
22
+ zen/telemetry/embedded_credentials.py,sha256=0ktJEeAwj_oLeSottOaCPEOnBXfqF7OYyAihiUOoRbA,3925
23
23
  zen/telemetry/manager.py,sha256=TtrIPOvRvq1OOhNAO_Tp-dz7EiS2xXfqnpKQduLwYoI,9731
24
- netra_zen-1.0.8.dist-info/METADATA,sha256=qO5AJ79GelX3ZmHMIS1sxy833cCKzIWAvGnVCZGoWn8,43383
25
- netra_zen-1.0.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
- netra_zen-1.0.8.dist-info/entry_points.txt,sha256=oDehCnPGZezG0m9ZWspxjHLHyQ3eERX87eojR4ljaRo,45
27
- netra_zen-1.0.8.dist-info/top_level.txt,sha256=OhiyXmoXftBijCF6ck-RS1dN2NBJv9wdd7kBG1Es7zA,77
28
- netra_zen-1.0.8.dist-info/RECORD,,
24
+ netra_zen-1.0.10.dist-info/METADATA,sha256=cxNP2UyMYb7LUBno5ID8VtTKzsW7Du4c6_5OD19J-Ic,43384
25
+ netra_zen-1.0.10.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
+ netra_zen-1.0.10.dist-info/entry_points.txt,sha256=oDehCnPGZezG0m9ZWspxjHLHyQ3eERX87eojR4ljaRo,45
27
+ netra_zen-1.0.10.dist-info/top_level.txt,sha256=OhiyXmoXftBijCF6ck-RS1dN2NBJv9wdd7kBG1Es7zA,77
28
+ netra_zen-1.0.10.dist-info/RECORD,,
scripts/agent_cli.py CHANGED
@@ -61,7 +61,7 @@ if not _stream_logs_active:
61
61
  logger.disabled = True
62
62
 
63
63
  from typing import Optional, Dict, Any, List
64
- from datetime import datetime, timedelta
64
+ from datetime import datetime, timedelta, timezone
65
65
  from pathlib import Path
66
66
  import aiohttp
67
67
  import websockets
@@ -1626,6 +1626,7 @@ class Config:
1626
1626
  skip_timeout_validation: bool = False # Issue #2483: Skip timeout hierarchy validation
1627
1627
  json_mode: bool = False # ISSUE #2766: JSON output mode - suppress console output
1628
1628
  ci_mode: bool = False # ISSUE #2766: CI mode - suppress Rich terminal output
1629
+ use_backend_threads: bool = True # SSOT: Use backend for thread ID management (can disable for backward compat)
1629
1630
 
1630
1631
  def get_websocket_url(self) -> str:
1631
1632
  """Get WebSocket URL for compatibility with test framework"""
@@ -2600,6 +2601,11 @@ class WebSocketClient:
2600
2601
  # ISSUE #2417 Phase 2: Store thread_id for filtering backend logs
2601
2602
  self.current_thread_id: Optional[str] = None
2602
2603
 
2604
+ # SSOT: Thread management cache for performance
2605
+ self.thread_cache_file = self._get_platform_cache_path()
2606
+ self.thread_cache: Dict[str, Dict[str, Any]] = {}
2607
+ self._load_thread_cache()
2608
+
2603
2609
  # Log forwarding configuration
2604
2610
  self.send_logs = send_logs
2605
2611
  self.logs_count = logs_count
@@ -2855,9 +2861,25 @@ class WebSocketClient:
2855
2861
  DebugLevel.BASIC,
2856
2862
  style="green"
2857
2863
  )
2858
- safe_console_print("SUCCESS: WebSocket connected successfully!", style="green")
2859
- self.connected = True
2860
- return True
2864
+
2865
+ # SSOT: Perform handshake to get backend-provided thread_id
2866
+ handshake_success = await self._perform_handshake()
2867
+ if handshake_success:
2868
+ safe_console_print(f"✅ Connected with thread ID: {self.current_thread_id}", style="green")
2869
+ self.connected = True
2870
+ return True
2871
+ else:
2872
+ # Handshake failed - backend might be old version
2873
+ self.debug.debug_print(
2874
+ "WARNING: Handshake failed - backend may not support thread agreement",
2875
+ DebugLevel.BASIC,
2876
+ style="yellow"
2877
+ )
2878
+
2879
+ # Still mark as connected for backward compatibility
2880
+ # Old backends might work without the handshake
2881
+ self.connected = True
2882
+ return True
2861
2883
  except Exception as e:
2862
2884
  self.debug.log_connection_attempt(method_name, self.config.ws_url, success=False, error=str(e))
2863
2885
  self.debug.debug_print(
@@ -2955,6 +2977,492 @@ class WebSocketClient:
2955
2977
  )
2956
2978
  return True
2957
2979
 
2980
+ async def _perform_handshake(self) -> bool:
2981
+ """
2982
+ SSOT: Perform handshake protocol to get backend-provided thread_id.
2983
+ This ensures both CLI and backend agree on the thread_id for proper event routing.
2984
+
2985
+ Protocol (tries both approaches):
2986
+ A. New protocol: Backend sends connection_established immediately
2987
+ B. Legacy protocol: CLI sends initial message to trigger backend response
2988
+ """
2989
+ try:
2990
+ import asyncio
2991
+
2992
+ self.debug.debug_print(
2993
+ "Starting handshake...",
2994
+ DebugLevel.VERBOSE,
2995
+ style="cyan"
2996
+ )
2997
+
2998
+ # Try to receive immediately (new protocol where backend sends first)
2999
+ try:
3000
+ # Non-blocking check if backend sent connection_established
3001
+ response_msg = await asyncio.wait_for(self.ws.recv(), timeout=2.0)
3002
+ response = json.loads(response_msg)
3003
+
3004
+ # Process if we got a connection_established immediately
3005
+ if response.get('type') == 'connection_established':
3006
+ self.debug.debug_print(
3007
+ "Received thread ID from backend",
3008
+ DebugLevel.VERBOSE,
3009
+ style="green"
3010
+ )
3011
+ return await self._process_connection_established(response)
3012
+ else:
3013
+ # Got a different message type - log it
3014
+ self.debug.debug_print(
3015
+ f"Got {response.get('type')} instead of connection_established",
3016
+ DebugLevel.VERBOSE,
3017
+ style="yellow"
3018
+ )
3019
+ except asyncio.TimeoutError:
3020
+ # Backend didn't send immediately, try sending a trigger message
3021
+ pass # Silent - this is normal for backends that wait for trigger
3022
+
3023
+ # If we didn't get connection_established immediately, send a trigger
3024
+ # This handles backends that wait for an initial message
3025
+ trigger_message = {
3026
+ "type": "handshake_request",
3027
+ "client_type": "cli",
3028
+ "client_version": "2.0.0",
3029
+ "timestamp": datetime.now(timezone.utc).isoformat()
3030
+ }
3031
+
3032
+ self.debug.debug_print(
3033
+ "Sending handshake_request...",
3034
+ DebugLevel.VERBOSE,
3035
+ style="cyan"
3036
+ )
3037
+
3038
+ await self.ws.send(json.dumps(trigger_message))
3039
+
3040
+ # Now wait for the response
3041
+ try:
3042
+ response_msg = await asyncio.wait_for(self.ws.recv(), timeout=10.0)
3043
+ response = json.loads(response_msg)
3044
+
3045
+ if response.get('type') == 'connection_established':
3046
+ return await self._process_connection_established(response)
3047
+ else:
3048
+ # Not a connection_established message - show actual response
3049
+ response_type = response.get('type', 'unknown')
3050
+ self.debug.debug_print(
3051
+ f"ERROR: Unexpected response type: '{response_type}'",
3052
+ DebugLevel.BASIC,
3053
+ style="red"
3054
+ )
3055
+
3056
+ # CRITICAL: Show the actual response data for debugging
3057
+ self.debug.debug_print(
3058
+ "ACTUAL RESPONSE DATA:",
3059
+ DebugLevel.BASIC,
3060
+ style="yellow"
3061
+ )
3062
+ self.debug.debug_print(
3063
+ json.dumps(response, indent=2),
3064
+ DebugLevel.BASIC,
3065
+ style="cyan"
3066
+ )
3067
+ return False
3068
+
3069
+ except asyncio.TimeoutError:
3070
+ # Timeout - log error concisely
3071
+ self.debug.debug_print(
3072
+ "ERROR: Handshake timeout - no response from backend",
3073
+ DebugLevel.BASIC,
3074
+ style="red"
3075
+ )
3076
+ return False
3077
+
3078
+ except Exception as e:
3079
+ # Handshake error - log but don't completely fail
3080
+ error_msg = f"WARNING: Handshake error: {e}"
3081
+ self.debug.log_error(e, "handshake protocol")
3082
+ self.debug.debug_print(error_msg, DebugLevel.BASIC, style="yellow")
3083
+ safe_console_print(error_msg, style="yellow")
3084
+ return False
3085
+
3086
+ async def _process_connection_established(self, response: Dict[str, Any]) -> bool:
3087
+ """
3088
+ Process a connection_established message from backend.
3089
+
3090
+ Args:
3091
+ response: The connection_established message from backend
3092
+
3093
+ Returns:
3094
+ True if thread_id was successfully extracted and acknowledged
3095
+ """
3096
+ # Extract all IDs from backend response
3097
+ backend_thread_id = response.get('thread_id')
3098
+ backend_run_id = response.get('run_id')
3099
+ backend_request_id = response.get('request_id')
3100
+ backend_session_token = response.get('session_token')
3101
+
3102
+ if not backend_thread_id:
3103
+ # Backend didn't provide thread_id
3104
+ self.debug.debug_print(
3105
+ "ERROR: Backend connection_established missing thread_id",
3106
+ DebugLevel.BASIC,
3107
+ style="red"
3108
+ )
3109
+ return False
3110
+
3111
+ # CRITICAL: Accept backend's thread_id as single source of truth
3112
+ self.current_thread_id = backend_thread_id
3113
+ self.run_id = backend_run_id # Store run_id if provided
3114
+ self._update_thread_cache(backend_thread_id)
3115
+
3116
+ self.debug.debug_print(
3117
+ f"Thread ID: {backend_thread_id}",
3118
+ DebugLevel.VERBOSE,
3119
+ style="green"
3120
+ )
3121
+
3122
+ # CRITICAL: Send acknowledgment with the SAME thread_id
3123
+ ack_message = {
3124
+ "type": "session_acknowledged",
3125
+ "thread_id": backend_thread_id, # Echo back the same ID
3126
+ "timestamp": datetime.now(timezone.utc).isoformat()
3127
+ }
3128
+
3129
+ await self.ws.send(json.dumps(ack_message))
3130
+
3131
+ return True
3132
+
3133
+ def _get_platform_cache_path(self) -> Path:
3134
+ """
3135
+ Get platform-appropriate cache directory path.
3136
+
3137
+ Windows: %LOCALAPPDATA%/Netra/CLI/thread_cache.json
3138
+ macOS: ~/Library/Application Support/Netra/CLI/thread_cache.json
3139
+ Linux: ~/.local/share/netra/cli/thread_cache.json or ~/.netra/thread_cache.json
3140
+ """
3141
+ import platform as stdlib_platform
3142
+
3143
+ system = stdlib_platform.system()
3144
+
3145
+ if system == "Windows":
3146
+ # Use Windows AppData/Local directory
3147
+ app_data = os.environ.get('LOCALAPPDATA')
3148
+ if app_data:
3149
+ cache_dir = Path(app_data) / "Netra" / "CLI"
3150
+ else:
3151
+ # Fallback to user home
3152
+ cache_dir = Path.home() / "AppData" / "Local" / "Netra" / "CLI"
3153
+ elif system == "Darwin": # macOS
3154
+ # Use macOS Application Support directory
3155
+ cache_dir = Path.home() / "Library" / "Application Support" / "Netra" / "CLI"
3156
+ else: # Linux and other Unix-like systems
3157
+ # Follow XDG Base Directory Specification
3158
+ xdg_data_home = os.environ.get('XDG_DATA_HOME')
3159
+ if xdg_data_home:
3160
+ cache_dir = Path(xdg_data_home) / "netra" / "cli"
3161
+ else:
3162
+ # Fallback to ~/.local/share or ~/.netra for compatibility
3163
+ local_share = Path.home() / ".local" / "share" / "netra" / "cli"
3164
+ if local_share.parent.exists():
3165
+ cache_dir = local_share
3166
+ else:
3167
+ # Legacy path for backward compatibility
3168
+ cache_dir = Path.home() / ".netra"
3169
+
3170
+ return cache_dir / "thread_cache.json"
3171
+
3172
+ def _load_thread_cache(self) -> None:
3173
+ """
3174
+ Load thread cache from disk for SSOT thread management.
3175
+
3176
+ Cache structure:
3177
+ {
3178
+ "user_id": {
3179
+ "thread_id": "backend_thread_123",
3180
+ "created_at": "2024-01-01T00:00:00",
3181
+ "last_used": "2024-01-01T00:00:00",
3182
+ "environment": "staging"
3183
+ }
3184
+ }
3185
+ """
3186
+ try:
3187
+ if self.thread_cache_file.exists():
3188
+ with open(self.thread_cache_file, 'r') as f:
3189
+ self.thread_cache = json.load(f)
3190
+ self.debug.debug_print(
3191
+ f"SSOT: Loaded thread cache with {len(self.thread_cache)} entries",
3192
+ DebugLevel.VERBOSE
3193
+ )
3194
+ except Exception as e:
3195
+ self.debug.debug_print(
3196
+ f"SSOT: Could not load thread cache: {e}",
3197
+ DebugLevel.TRACE
3198
+ )
3199
+ self.thread_cache = {}
3200
+
3201
+ def _save_thread_cache(self) -> None:
3202
+ """Save thread cache to disk for persistence."""
3203
+ try:
3204
+ # Ensure directory exists
3205
+ self.thread_cache_file.parent.mkdir(parents=True, exist_ok=True)
3206
+
3207
+ # Save cache
3208
+ with open(self.thread_cache_file, 'w') as f:
3209
+ json.dump(self.thread_cache, f, indent=2)
3210
+
3211
+ self.debug.debug_print(
3212
+ "SSOT: Thread cache saved successfully",
3213
+ DebugLevel.TRACE
3214
+ )
3215
+ except Exception as e:
3216
+ self.debug.debug_print(
3217
+ f"SSOT: Could not save thread cache: {e}",
3218
+ DebugLevel.TRACE
3219
+ )
3220
+
3221
+ def _get_cached_thread_id(self) -> Optional[str]:
3222
+ """
3223
+ Get cached thread ID for current user and environment.
3224
+
3225
+ SSOT: Uses cached thread but validates with backend.
3226
+ """
3227
+ try:
3228
+ # Get user identifier from token
3229
+ if not self.token:
3230
+ return None
3231
+
3232
+ # Decode token to get user_id
3233
+ payload = jwt.decode(self.token, options={"verify_signature": False})
3234
+ user_id = payload.get('user_id') or payload.get('sub') or payload.get('email')
3235
+
3236
+ if not user_id:
3237
+ return None
3238
+
3239
+ # Check cache for this user
3240
+ if user_id in self.thread_cache:
3241
+ cached_data = self.thread_cache[user_id]
3242
+
3243
+ # Check if cache is for same environment
3244
+ cached_env = cached_data.get('environment')
3245
+ current_env = self.config.environment.value if hasattr(self.config, 'environment') else None
3246
+
3247
+ if cached_env == current_env:
3248
+ thread_id = cached_data.get('thread_id')
3249
+ last_used = cached_data.get('last_used')
3250
+
3251
+ # Check if cache is recent (within 24 hours)
3252
+ if last_used:
3253
+ last_used_dt = datetime.fromisoformat(last_used)
3254
+ if datetime.now() - last_used_dt < timedelta(hours=24):
3255
+ self.debug.debug_print(
3256
+ f"SSOT: Found cached thread_id: {thread_id}",
3257
+ DebugLevel.VERBOSE
3258
+ )
3259
+ return thread_id
3260
+
3261
+ except Exception as e:
3262
+ self.debug.debug_print(
3263
+ f"SSOT: Error accessing thread cache: {e}",
3264
+ DebugLevel.TRACE
3265
+ )
3266
+
3267
+ return None
3268
+
3269
+ def _update_thread_cache(self, thread_id: str) -> None:
3270
+ """Update thread cache with new or validated thread ID."""
3271
+ try:
3272
+ # Get user identifier
3273
+ payload = jwt.decode(self.token, options={"verify_signature": False})
3274
+ user_id = payload.get('user_id') or payload.get('sub') or payload.get('email')
3275
+
3276
+ if user_id:
3277
+ # Update cache entry
3278
+ self.thread_cache[user_id] = {
3279
+ 'thread_id': thread_id,
3280
+ 'created_at': self.thread_cache.get(user_id, {}).get('created_at', datetime.now().isoformat()),
3281
+ 'last_used': datetime.now().isoformat(),
3282
+ 'environment': self.config.environment.value if hasattr(self.config, 'environment') else "unknown"
3283
+ }
3284
+
3285
+ # Save to disk
3286
+ self._save_thread_cache()
3287
+
3288
+ self.debug.debug_print(
3289
+ f"SSOT: Updated thread cache for user {user_id[:10]}...",
3290
+ DebugLevel.TRACE
3291
+ )
3292
+
3293
+ except Exception as e:
3294
+ self.debug.debug_print(
3295
+ f"SSOT: Could not update thread cache: {e}",
3296
+ DebugLevel.TRACE
3297
+ )
3298
+
3299
+ async def get_or_create_thread_from_backend(self) -> Optional[str]:
3300
+ """
3301
+ SSOT: Get or create a thread ID from the backend.
3302
+
3303
+ This ensures thread IDs are managed by the backend as the single source of truth,
3304
+ not generated locally by the client.
3305
+
3306
+ Returns:
3307
+ Thread ID from backend, or None if creation fails
3308
+ """
3309
+ # Check if backend thread management is disabled
3310
+ if not self.config.use_backend_threads:
3311
+ self.debug.debug_print(
3312
+ "SSOT: Backend thread management disabled by configuration",
3313
+ DebugLevel.VERBOSE
3314
+ )
3315
+ return None
3316
+
3317
+ try:
3318
+ # First check if we have a cached thread_id for this session
3319
+ if self.current_thread_id and await self._validate_thread_with_backend(self.current_thread_id):
3320
+ self.debug.debug_print(
3321
+ f"SSOT: Using existing validated thread_id: {self.current_thread_id}",
3322
+ DebugLevel.VERBOSE
3323
+ )
3324
+ self._update_thread_cache(self.current_thread_id)
3325
+ return self.current_thread_id
3326
+
3327
+ # Check persistent cache for thread ID
3328
+ cached_thread_id = self._get_cached_thread_id()
3329
+ if cached_thread_id and await self._validate_thread_with_backend(cached_thread_id):
3330
+ self.current_thread_id = cached_thread_id
3331
+ self.debug.debug_print(
3332
+ f"SSOT: Using cached and validated thread_id: {cached_thread_id}",
3333
+ DebugLevel.VERBOSE
3334
+ )
3335
+ self._update_thread_cache(cached_thread_id)
3336
+ return cached_thread_id
3337
+
3338
+ # Create a new thread via backend API
3339
+ thread_id = await self._create_thread_on_backend()
3340
+ if thread_id:
3341
+ self.current_thread_id = thread_id
3342
+ self._update_thread_cache(thread_id)
3343
+ self.debug.debug_print(
3344
+ f"SSOT: Created new thread_id from backend: {thread_id}",
3345
+ DebugLevel.BASIC,
3346
+ style="green"
3347
+ )
3348
+ return thread_id
3349
+
3350
+ # Fallback: Use local generation with warning (backward compatibility)
3351
+ self.debug.debug_print(
3352
+ "SSOT WARNING: Backend thread creation failed, falling back to local generation",
3353
+ DebugLevel.BASIC,
3354
+ style="yellow"
3355
+ )
3356
+ return None
3357
+
3358
+ except Exception as e:
3359
+ self.debug.debug_print(
3360
+ f"SSOT ERROR: Thread management failed: {e}",
3361
+ DebugLevel.BASIC,
3362
+ style="red"
3363
+ )
3364
+ return None
3365
+
3366
+ async def _create_thread_on_backend(self) -> Optional[str]:
3367
+ """
3368
+ Create a new thread on the backend and return its ID.
3369
+
3370
+ SSOT: Backend is the authoritative source for thread IDs.
3371
+ """
3372
+ try:
3373
+ # Construct the thread creation endpoint
3374
+ thread_url = f"{self.config.backend_url}/api/threads/create"
3375
+
3376
+ headers = {
3377
+ "Authorization": f"Bearer {self.token}",
3378
+ "Content-Type": "application/json"
3379
+ }
3380
+
3381
+ # Thread creation payload with metadata
3382
+ payload = {
3383
+ "source": "agent_cli",
3384
+ "environment": self.config.environment.value if hasattr(self.config, 'environment') else "unknown",
3385
+ "client_version": "1.0.0", # Could be made configurable
3386
+ "timestamp": datetime.now().isoformat()
3387
+ }
3388
+
3389
+ # Use aiohttp session if available, otherwise create one
3390
+ import aiohttp
3391
+ async with aiohttp.ClientSession() as session:
3392
+ async with session.post(thread_url, json=payload, headers=headers) as response:
3393
+ if response.status == 200 or response.status == 201:
3394
+ data = await response.json()
3395
+ thread_id = data.get("thread_id") or data.get("id")
3396
+ if thread_id:
3397
+ self.debug.debug_print(
3398
+ f"SSOT: Backend created thread with ID: {thread_id}",
3399
+ DebugLevel.VERBOSE
3400
+ )
3401
+ return thread_id
3402
+ else:
3403
+ error_text = await response.text()
3404
+ self.debug.debug_print(
3405
+ f"SSOT: Backend thread creation failed with status {response.status}: {error_text}",
3406
+ DebugLevel.BASIC,
3407
+ style="yellow"
3408
+ )
3409
+
3410
+ except aiohttp.ClientError as e:
3411
+ # Network or connection errors - expected in some environments
3412
+ self.debug.debug_print(
3413
+ f"SSOT: Backend thread API not available (network error): {e}",
3414
+ DebugLevel.VERBOSE
3415
+ )
3416
+ except Exception as e:
3417
+ self.debug.debug_print(
3418
+ f"SSOT: Unexpected error creating backend thread: {e}",
3419
+ DebugLevel.VERBOSE
3420
+ )
3421
+
3422
+ return None
3423
+
3424
+ async def _validate_thread_with_backend(self, thread_id: str) -> bool:
3425
+ """
3426
+ Validate that a thread ID exists and is valid on the backend.
3427
+
3428
+ SSOT: Backend validates thread existence and status.
3429
+ """
3430
+ try:
3431
+ # Quick validation - check if thread exists on backend
3432
+ validate_url = f"{self.config.backend_url}/api/threads/{thread_id}/validate"
3433
+
3434
+ headers = {
3435
+ "Authorization": f"Bearer {self.token}"
3436
+ }
3437
+
3438
+ import aiohttp
3439
+ async with aiohttp.ClientSession() as session:
3440
+ async with session.get(validate_url, headers=headers) as response:
3441
+ if response.status == 200:
3442
+ data = await response.json()
3443
+ is_valid = data.get("valid", False)
3444
+ if is_valid:
3445
+ self.debug.debug_print(
3446
+ f"SSOT: Thread {thread_id} validated successfully",
3447
+ DebugLevel.TRACE
3448
+ )
3449
+ return is_valid
3450
+ elif response.status == 404:
3451
+ self.debug.debug_print(
3452
+ f"SSOT: Thread {thread_id} not found on backend",
3453
+ DebugLevel.VERBOSE
3454
+ )
3455
+ return False
3456
+
3457
+ except Exception as e:
3458
+ # If validation fails, assume thread is invalid
3459
+ self.debug.debug_print(
3460
+ f"SSOT: Thread validation failed for {thread_id}: {e}",
3461
+ DebugLevel.TRACE
3462
+ )
3463
+
3464
+ return False
3465
+
2958
3466
  async def send_message(self, message: str) -> str:
2959
3467
  """Send a message and return the run_id"""
2960
3468
  if not self.ws:
@@ -2965,12 +3473,73 @@ class WebSocketClient:
2965
3473
 
2966
3474
  # Create message payload
2967
3475
  # ISSUE #1671 FIX: Add thread_id for proper WebSocket event routing
2968
- # The WebSocket manager expects both user_id and thread_id to route events correctly
2969
- # ISSUE #2782: Simple UUID generation - no backend dependency
2970
- thread_id = f"cli_thread_{uuid.uuid4().hex[:12]}"
3476
+ # SSOT: Thread ID from backend is REQUIRED
3477
+ if not self.current_thread_id:
3478
+ # Detailed error diagnostics
3479
+ self.debug.debug_print(
3480
+ "CRITICAL ERROR: Cannot send message - no thread ID available",
3481
+ DebugLevel.BASIC,
3482
+ style="red"
3483
+ )
3484
+ self.debug.debug_print(
3485
+ "CAUSE: Backend handshake did not complete successfully",
3486
+ DebugLevel.BASIC,
3487
+ style="yellow"
3488
+ )
2971
3489
 
2972
- # ISSUE #2417 Phase 2: Store thread_id for filtering backend logs
2973
- self.current_thread_id = thread_id
3490
+ # User-facing error with actionable guidance
3491
+ safe_console_print(
3492
+ "\n❌ ERROR: Cannot send message - thread ID not established",
3493
+ style="red"
3494
+ )
3495
+ safe_console_print(
3496
+ "\n🔍 TROUBLESHOOTING STEPS:",
3497
+ style="yellow"
3498
+ )
3499
+ safe_console_print(
3500
+ " 1. Check if backend is running the latest version",
3501
+ style="dim"
3502
+ )
3503
+ safe_console_print(
3504
+ " 2. Verify backend has CLIHandshakeProtocol implemented",
3505
+ style="dim"
3506
+ )
3507
+ safe_console_print(
3508
+ " 3. Check backend logs for WebSocket connection errors",
3509
+ style="dim"
3510
+ )
3511
+ safe_console_print(
3512
+ " 4. Try running with --debug-level=verbose for more details",
3513
+ style="dim"
3514
+ )
3515
+ safe_console_print(
3516
+ "\n📝 WHAT HAPPENED:",
3517
+ style="cyan"
3518
+ )
3519
+ safe_console_print(
3520
+ " • CLI connected to WebSocket successfully",
3521
+ style="dim"
3522
+ )
3523
+ safe_console_print(
3524
+ " • Backend did not provide a thread ID during handshake",
3525
+ style="dim"
3526
+ )
3527
+ safe_console_print(
3528
+ " • Without thread ID, events cannot be properly routed",
3529
+ style="dim"
3530
+ )
3531
+
3532
+ raise RuntimeError(
3533
+ "Thread ID not established with backend. "
3534
+ "See troubleshooting steps above."
3535
+ )
3536
+
3537
+ thread_id = self.current_thread_id
3538
+ self.debug.debug_print(
3539
+ f"SSOT: Using backend-provided thread_id: {thread_id}",
3540
+ DebugLevel.VERBOSE,
3541
+ style="green"
3542
+ )
2974
3543
 
2975
3544
  # ISSUE #1673 FIX: Backend expects payload structure with nested data
2976
3545
  # The backend AgentServiceCore._parse_message expects:
@@ -3164,6 +3733,15 @@ class WebSocketClient:
3164
3733
  )
3165
3734
  self.events.append(event)
3166
3735
 
3736
+ # Skip connection_established - already handled in handshake
3737
+ # This prevents duplicate processing since handshake now waits for it first
3738
+ if event.type == 'connection_established' and self.current_thread_id:
3739
+ self.debug.debug_print(
3740
+ f"SSOT: Ignoring duplicate connection_established (thread already set: {self.current_thread_id})",
3741
+ DebugLevel.VERBOSE,
3742
+ style="dim"
3743
+ )
3744
+
3167
3745
  self.debug.debug_print(
3168
3746
  f"GOLDEN PATH TRACE: Parsed WebSocket event type={event.type}",
3169
3747
  DebugLevel.BASIC,
@@ -5475,6 +6053,26 @@ def main(argv=None):
5475
6053
  help="Disable WebSocket error diagnostics (opt-out)"
5476
6054
  )
5477
6055
 
6056
+ # SSOT Thread Management Arguments
6057
+ parser.add_argument(
6058
+ "--handshake-timeout",
6059
+ type=float,
6060
+ default=5.0,
6061
+ help="Timeout for handshake with backend (seconds, default: 5.0)"
6062
+ )
6063
+
6064
+ parser.add_argument(
6065
+ "--disable-backend-threads",
6066
+ action="store_true",
6067
+ help="SSOT: Disable backend thread ID management and use local generation (backward compatibility)"
6068
+ )
6069
+
6070
+ parser.add_argument(
6071
+ "--clear-thread-cache",
6072
+ action="store_true",
6073
+ help="SSOT: Clear cached thread IDs and force new thread creation"
6074
+ )
6075
+
5478
6076
  parser.add_argument(
5479
6077
  "--health-check",
5480
6078
  action="store_true",
@@ -5842,7 +6440,8 @@ def main(argv=None):
5842
6440
  enable_websocket_diagnostics=enable_diagnostics, # Issue #2484 Phase 2: Default enabled with opt-out
5843
6441
  skip_timeout_validation=args.skip_timeout_validation, # Issue #2483: Skip timeout hierarchy validation
5844
6442
  json_mode=json_mode, # ISSUE #2766: Pass json_mode to config for output suppression
5845
- ci_mode=ci_mode # ISSUE #2766: Pass ci_mode to config for output suppression
6443
+ ci_mode=ci_mode, # ISSUE #2766: Pass ci_mode to config for output suppression
6444
+ use_backend_threads=not args.disable_backend_threads # SSOT: Backend thread management (enabled by default)
5846
6445
  )
5847
6446
 
5848
6447
  # ISSUE #2839: Load validation framework imports when validation is explicitly requested
@@ -5890,6 +6489,31 @@ def main(argv=None):
5890
6489
  safe_console_print("SUCCESS: Cleared cached authentication token", style="green",
5891
6490
  json_mode=json_mode, ci_mode=ci_mode)
5892
6491
 
6492
+ # SSOT: Clear thread cache if requested
6493
+ if args.clear_thread_cache:
6494
+ # Use platform-aware cache path
6495
+ from pathlib import Path
6496
+ import platform as stdlib_platform
6497
+
6498
+ system = stdlib_platform.system()
6499
+ if system == "Windows":
6500
+ app_data = os.environ.get('LOCALAPPDATA', str(Path.home() / "AppData" / "Local"))
6501
+ thread_cache_file = Path(app_data) / "Netra" / "CLI" / "thread_cache.json"
6502
+ elif system == "Darwin":
6503
+ thread_cache_file = Path.home() / "Library" / "Application Support" / "Netra" / "CLI" / "thread_cache.json"
6504
+ else:
6505
+ xdg_data = os.environ.get('XDG_DATA_HOME', str(Path.home() / ".local" / "share"))
6506
+ thread_cache_file = Path(xdg_data) / "netra" / "cli" / "thread_cache.json"
6507
+ # Also check legacy location
6508
+ legacy_cache = Path.home() / ".netra" / "thread_cache.json"
6509
+ if legacy_cache.exists():
6510
+ legacy_cache.unlink()
6511
+
6512
+ if thread_cache_file.exists():
6513
+ thread_cache_file.unlink()
6514
+ safe_console_print("SUCCESS: Cleared cached thread IDs", style="green",
6515
+ json_mode=json_mode, ci_mode=ci_mode)
6516
+
5893
6517
  # ISSUE #2766: json_mode and ci_mode already determined at top of main()
5894
6518
  json_output_file = args.json_output
5895
6519
 
@@ -6285,6 +6909,15 @@ def main(argv=None):
6285
6909
  sys.exit(1)
6286
6910
  elif args.send_logs:
6287
6911
  # Handle --send-logs without --message: use default message
6912
+ # the jsonl logs are attached in payload
6913
+ # {
6914
+ # "type": "user_message",
6915
+ # "payload": {
6916
+ # ...
6917
+ # *** "jsonl_logs": [... actual logs here ...] ***
6918
+ # }
6919
+ #}
6920
+
6288
6921
  default_message = "claude-code optimizer default message"
6289
6922
  result = await cli.run_single_message(default_message, args.wait)
6290
6923
  # ISSUE #2766: Use structured exit code from ExitCodeGenerator
@@ -1,50 +1,20 @@
1
- """Runtime loader for telemetry credentials from environment variables."""
2
-
3
- from __future__ import annotations
1
+ """Embedded telemetry credentials. AUTO-GENERATED - DO NOT COMMIT."""
4
2
 
5
3
  import base64
6
4
  import json
7
- import os
8
- from pathlib import Path
9
- from typing import Optional
10
-
11
5
  from google.oauth2 import service_account
12
6
 
13
- _ENV_B64 = "COMMUNITY_CREDENTIALS"
14
- _ENV_PATH = "ZEN_COMMUNITY_TELEMETRY_FILE"
15
- _ENV_PROJECT = "ZEN_COMMUNITY_TELEMETRY_PROJECT"
16
- _DEFAULT_PROJECT = "netra-telemetry-public"
17
-
18
-
19
- def _load_service_account_dict() -> Optional[dict]:
20
- """Load service account JSON from environment variables."""
21
- encoded = os.getenv(_ENV_B64)
22
- if encoded:
23
- try:
24
- raw = base64.b64decode(encoded)
25
- return json.loads(raw)
26
- except (ValueError, json.JSONDecodeError):
27
- return None
28
-
29
- path = os.getenv(_ENV_PATH)
30
- if path:
31
- candidate = Path(path).expanduser()
32
- if candidate.exists():
33
- try:
34
- return json.loads(candidate.read_text())
35
- except json.JSONDecodeError:
36
- return None
37
- return None
7
+ _EMBEDDED_CREDENTIALS_B64 = 'ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAibmV0cmEtdGVsZW1ldHJ5LXB1YmxpYyIsCiAgInByaXZhdGVfa2V5X2lkIjogImVjOWM4ZGNlZGZmMTUzNjM5YTUxOTcyMzc0MjYyNjkwNjZkNzAxYTQiLAogICJwcml2YXRlX2tleSI6ICItLS0tLUJFR0lOIFBSSVZBVEUgS0VZLS0tLS1cbk1JSUV2Z0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktnd2dnU2tBZ0VBQW9JQkFRREhUcmZFOHlQdUFCTDNcbk5aS3diZ1AwamRyaWRnY0UwMUlLMks1YkZ1bWFrUHVrRGxzV0dVaUswOXEyaVNYTWVUQmJPZDF0VjFoc3VJcUhcbnpQK0pZd0NTcVp4S0pIQS8yWUdVcERqeWhHRVd3QTNtS3laVXVUUS9yUTBUK20yV0cwdEMxUzBpQzB6U211cEpcbjBNeGhUUDRjKzYreEczSDVSWEF1YjdONmx6eFFWVnJSY3FHMTYydlF5SFA2OWIrd2RidTJHM0o5UkJVN1VUK1FcbkU2RHB2K01YaEp3MnRPdHZLbFBQT3BnWm9Sb0pmeXU1WlpzbHlJZCtzY3FhUTY5ZjBaSmpIRjlYQVdlT25mUTRcbmExbmV0LzJqRjZibWpuZmQ2MjhBODA5cEluTXJEL0FwZjJzUWJJdXJIYUI4am5uTEQ0eDMrbVhXRTgyMFJLNktcbjViZ0FQanNoQWdNQkFBRUNnZ0VBQmZERVZMWVlDakFkb0pscnpyOHF0a056cEpnV3Y3ZXlQSEZHcWgraDFSbndcbjdDd0Mza0xnNlFWbFFaZFBKWHZ2dTJwYlBaYnl3MlBST2ppN25adFNNU3pseEFaM3c0bHV2YkRTNHpTYnBiZFJcbityd3F3Mi8xUFJnaCtZaFhjNWZLNjVvcHd4Zmg0VzJkWWRlYnZlTXkrRWR1cmtsV2dYTG13L1dQbkkzdExlbzlcbjV1elZjbU42Qk04YkU3azFYK1M0RURBS0VRWlprUEdzTFQ4RXN4UmdWOWtnT1Zicm5VQ1Z0dXA1Q0NGbUR3U1Bcbmg0U25wMEsvTUp3b1U3NG4reTlFMXYxUXRnajE5TkhaNHJ2dFpnUlVaandHQy9Cc3ZkcE1PazArZTJEMlgvRk9cblZnc29xS2tDaklWUzRMcG5YSEpZbU5oajZWNHRXUnZ1OW1NTXhTL3FBUUtCZ1FEb3hZenlsdEZKL242ZEQvUHlcbnZLOFRaTHd5dFdCcjBXU3ZHU2VzM0JYRGZoKzBFbU4rRHpnZGdUb0ovbkhpazM5R21QM0tLN2htOFVvaFFHRy9cbkh0SFRuS0lBQlhrSU8yd2Z3N0h0V2pPTXRocHp2dFQxcmVEVHVjVk0wc2lCMHpjTldCMjFUamdQL3JYY2Q3NWVcbklERmNBN0hTbUJDLzB4bzk3aC8wV2YvOW9RS0JnUURiTWtnbjlVR2Y2SVRMNmxTcDdrYWJGL0NuaE4yU2VTMVdcbnd3R21iRThxTTU0UitDcVRUeHk2UHBRaFVSczlHM1VpVmQ1SXZWVDhuT095ZVBZVFJEbnFCYjJ4S214SFRodlZcbnVQcTgwQXB3anBMbzh6VkFDSy9iVFVjSmlKVGFBdXFHaXI1Ykc4YUlldVpIc0pLeWJ1NmhoNkhXMldwWXVVV1BcbkZ3TTl4elpOZ1FLQmdRQzg5dHJVZVJFUVE3VGZwbnJBM09JNEdUZ2E1bG1QVFo2eDh2YmRncEY4Y2FBbEhDUitcbnlyWWdaYThMTysrU0kzRllpNHpFR2pnS0FlblBFcWdIY21xZW9uSjFGL3hJYll6NlFIRHFJYWJsblZQZUVOWnJcblY2dkQxZlRReC9FVVM3Wk9jL0V5Slh5bnAzeFZyVFB5ejZtaWJERm9xQ0E0eVpSdElDbjZ3VEZxNFFLQmdFWFJcbnAxQXErOE0rb2dYOTF3ZmxvTkhIOTF5MG9vc0VWQis5cjZuZDkvMWVRYXhCbXZZZkRleDVBRi80WUsrL0xqbElcbmxxd2V1cEpZT3VMZlNxcHFZZlFiN2djZmx5dkRRblI2SGt2RURIODd1cW0reGlobVcvV0RrT3dGZUR4VkQzVFpcbmZyYXdpelZ2eUNmdm8xcDRvVVFNV3MxL3BUTXJtRzl5aWhMRWdKU0JBb0dCQU1GWm50ZUtUUDZrVVdrVmpOcndcbmUvQzBDbjJ6dk1YNXVnZURkS1FWNkwrY25mRWlRSzdzZ3R5eFp5ek5kMC82QXJ0YnBrcS9wcVlaYXpwVzVFMkxcbkxVMUF3MmdHT25GRlh2ZXg4aXpOZXViMGdvUVE4d3BtL3lrMVNVekR6VTV1dCtPbVFFRmpsbUYrNDkza0ZYcC9cbnc1MWh2WjVVL2loL1NYbjN6cjdEWE5QYlxuLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLVxuIiwKICAiY2xpZW50X2VtYWlsIjogInplbi1jb21tdW5pdHktdGVsZW1ldHJ5QG5ldHJhLXRlbGVtZXRyeS1wdWJsaWMuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLAogICJjbGllbnRfaWQiOiAiMTE0NzAwMDA0NzA1MDUxODg5NTY4IiwKICAiYXV0aF91cmkiOiAiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tL28vb2F1dGgyL2F1dGgiLAogICJ0b2tlbl91cmkiOiAiaHR0cHM6Ly9vYXV0aDIuZ29vZ2xlYXBpcy5jb20vdG9rZW4iLAogICJhdXRoX3Byb3ZpZGVyX3g1MDlfY2VydF91cmwiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vb2F1dGgyL3YxL2NlcnRzIiwKICAiY2xpZW50X3g1MDlfY2VydF91cmwiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vcm9ib3QvdjEvbWV0YWRhdGEveDUwOS96ZW4tY29tbXVuaXR5LXRlbGVtZXRyeSU0MG5ldHJhLXRlbGVtZXRyeS1wdWJsaWMuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLAogICJ1bml2ZXJzZV9kb21haW4iOiAiZ29vZ2xlYXBpcy5jb20iCn0K'
8
+ _CREDENTIALS_DICT = json.loads(
9
+ base64.b64decode(_EMBEDDED_CREDENTIALS_B64.encode("utf-8"))
10
+ )
38
11
 
39
12
 
40
13
  def get_embedded_credentials():
41
- """Return service account credentials or None."""
42
- info = _load_service_account_dict()
43
- if not info:
44
- return None
14
+ """Return service account credentials."""
45
15
  try:
46
16
  return service_account.Credentials.from_service_account_info(
47
- info,
17
+ _CREDENTIALS_DICT,
48
18
  scopes=["https://www.googleapis.com/auth/trace.append"],
49
19
  )
50
20
  except Exception:
@@ -52,8 +22,5 @@ def get_embedded_credentials():
52
22
 
53
23
 
54
24
  def get_project_id() -> str:
55
- """Return GCP project ID for telemetry."""
56
- info = _load_service_account_dict()
57
- if info and "project_id" in info:
58
- return info["project_id"]
59
- return os.getenv(_ENV_PROJECT, _DEFAULT_PROJECT)
25
+ """Return GCP project ID."""
26
+ return _CREDENTIALS_DICT.get("project_id", 'netra-telemetry-public')