netra-zen 1.0.11__py3-none-any.whl → 1.2.0__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.11
3
+ Version: 1.2.0
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=hKeP2dW6eJgEhtUHYylnhIgP_HgaxrDAwCnlS7mKkgU,156167
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.11.dist-info/licenses/LICENSE.md,sha256=PZrP0UDn58i4LjV4zijIQTnsQPvWm4zq9Fet9i7qgwI,80
4
+ netra_zen-1.2.0.dist-info/licenses/LICENSE.md,sha256=PZrP0UDn58i4LjV4zijIQTnsQPvWm4zq9Fet9i7qgwI,80
5
5
  scripts/__init__.py,sha256=r6jX6e9SaisREml6B7uDoV-_rzIW0_yQXMm6OhNgbIw,52
6
6
  scripts/__main__.py,sha256=NSqyN47EijNf7m_8QsoxeYevKYgH2sNtNPFZcwKWMs0,160
7
- scripts/agent_cli.py,sha256=nN_uVvvMvFznEeIo4aHx3hBpXov5BkloQHcC6quAGFE,338473
7
+ scripts/agent_cli.py,sha256=ATQIWoVx7GthFV1WeiV6Co8PzoXYXhlJjOerU-JocJ0,345625
8
8
  scripts/agent_logs.py,sha256=ppseAtUCvS2N-iEDSUQh84I9Ov-gBEw5ElxYajNdpJs,11218
9
9
  scripts/bump_version.py,sha256=fjABzzRVXJ00CbYMpUIUMwcOHwafLYtFL6NvUga-i6M,4183
10
10
  scripts/demo_log_collection.py,sha256=5XladS8j4lz-jJdVzSeDSxopChqHKX7HLNJUzSWR40c,5623
@@ -23,8 +23,8 @@ zen/telemetry/__init__.py,sha256=zyH7YcK_eWxwaQZW0MRefu1bX5hEgfRljr_dsqNU-tw,452
23
23
  zen/telemetry/apex_telemetry.py,sha256=FAkiHXOuzZFL-51oYetTC4wbjCL5UH-qOghwcaaOVgE,9685
24
24
  zen/telemetry/embedded_credentials.py,sha256=z-j_TfuVIz3nX-Vvh4O6_iDQhtteEyQgfc-xSslVbXU,1716
25
25
  zen/telemetry/manager.py,sha256=Rdbpnjbjel9xZEJyvLqy2M-4amvkr80abRiWnqHghIQ,9980
26
- netra_zen-1.0.11.dist-info/METADATA,sha256=0NbAxCCoF9-SB30FfxA2lU0oo0da1XNtNvSHkrRZX9I,46284
27
- netra_zen-1.0.11.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
- netra_zen-1.0.11.dist-info/entry_points.txt,sha256=oDehCnPGZezG0m9ZWspxjHLHyQ3eERX87eojR4ljaRo,45
29
- netra_zen-1.0.11.dist-info/top_level.txt,sha256=OhiyXmoXftBijCF6ck-RS1dN2NBJv9wdd7kBG1Es7zA,77
30
- netra_zen-1.0.11.dist-info/RECORD,,
26
+ netra_zen-1.2.0.dist-info/METADATA,sha256=sb-1CUEfEqK3rmHbqxqrNeMi4t5d_hZ34xhBoPT1a90,46283
27
+ netra_zen-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
+ netra_zen-1.2.0.dist-info/entry_points.txt,sha256=oDehCnPGZezG0m9ZWspxjHLHyQ3eERX87eojR4ljaRo,45
29
+ netra_zen-1.2.0.dist-info/top_level.txt,sha256=OhiyXmoXftBijCF6ck-RS1dN2NBJv9wdd7kBG1Es7zA,77
30
+ netra_zen-1.2.0.dist-info/RECORD,,
scripts/agent_cli.py CHANGED
@@ -216,7 +216,9 @@ EMOJI_FALLBACKS = {
216
216
  '🔥': '[HOT]',
217
217
  '💭': '[THOUGHT]',
218
218
  '📥': '[INPUT]',
219
- '📤': '[OUTPUT]'
219
+ '📤': '[OUTPUT]',
220
+ '📍': '[ENV]',
221
+ '🔗': '[LINK]'
220
222
  }
221
223
 
222
224
  def detect_terminal_capabilities(override_mode: str = None) -> str:
@@ -1592,18 +1594,18 @@ def show_startup_banner(config):
1592
1594
  config: Config object containing environment, backend_url, and auth_url
1593
1595
  """
1594
1596
  print()
1595
- print("" * 75)
1596
- print("🤖 Netra Agent CLI - Interactive Mode")
1597
- print("" * 75)
1597
+ print("=" * 75)
1598
+ safe_console_print("🤖 Netra Agent CLI - Interactive Mode", display_mode=detect_terminal_capabilities())
1599
+ print("=" * 75)
1598
1600
  print()
1599
- print(f"📍 Environment: {config.environment.value.upper()}")
1601
+ safe_console_print(f"📍 Environment: {config.environment.value.upper()}", display_mode=detect_terminal_capabilities())
1600
1602
  print()
1601
1603
 
1602
- print("🔗 Endpoints: ")
1604
+ safe_console_print("🔗 Endpoints: ", display_mode=detect_terminal_capabilities())
1603
1605
  print(f"Backend: {config.backend_url}")
1604
1606
  print(f"Auth: {config.auth_url}")
1605
1607
  print()
1606
- print("" * 75)
1608
+ print("=" * 75)
1607
1609
  print()
1608
1610
 
1609
1611
 
@@ -2881,17 +2883,49 @@ class WebSocketClient:
2881
2883
  self.connected = True
2882
2884
  return True
2883
2885
  else:
2884
- # Handshake failed - backend might be old version
2886
+ # Handshake failed - server not ready to process messages
2885
2887
  self.debug.debug_print(
2886
- "WARNING: Handshake failed - backend may not support thread agreement",
2888
+ "WARNING: Server handshake not completed - server not ready",
2887
2889
  DebugLevel.BASIC,
2888
2890
  style="yellow"
2889
2891
  )
2892
+ self.debug.debug_print(
2893
+ "Server still completing lifecycle phases (Initialize → Authenticate → Handshake → Prepare → Processing)",
2894
+ DebugLevel.VERBOSE,
2895
+ style="yellow"
2896
+ )
2890
2897
 
2891
- # Still mark as connected for backward compatibility
2892
- # Old backends might work without the handshake
2893
- self.connected = True
2894
- return True
2898
+ # Wait briefly for server to complete its phases and retry
2899
+ safe_console_print("⏳ Waiting for server to complete initialization...", style="yellow",
2900
+ json_mode=self.config.json_mode, ci_mode=self.config.ci_mode)
2901
+ await asyncio.sleep(3.0) # Give server time to reach PROCESSING phase
2902
+
2903
+ # Retry handshake once more
2904
+ self.debug.debug_print(
2905
+ "Retrying handshake after delay...",
2906
+ DebugLevel.VERBOSE,
2907
+ style="cyan"
2908
+ )
2909
+ handshake_success = await self._perform_handshake()
2910
+ if handshake_success:
2911
+ safe_console_print(f"✅ Connected with thread ID: {self.current_thread_id}", style="green",
2912
+ json_mode=self.config.json_mode, ci_mode=self.config.ci_mode)
2913
+ self.connected = True
2914
+ return True
2915
+ else:
2916
+ # Server still not ready - fail the connection
2917
+ self.debug.debug_print(
2918
+ "ERROR: Server not ready after retry",
2919
+ DebugLevel.BASIC,
2920
+ style="red"
2921
+ )
2922
+ safe_console_print("❌ Server not ready to process messages. Please try again.", style="red",
2923
+ json_mode=self.config.json_mode, ci_mode=self.config.ci_mode)
2924
+
2925
+ # Close WebSocket and fail gracefully
2926
+ if self.ws:
2927
+ await self.ws.close()
2928
+ return False
2895
2929
  except Exception as e:
2896
2930
  self.debug.log_connection_attempt(method_name, self.config.ws_url, success=False, error=str(e))
2897
2931
  self.debug.debug_print(
@@ -2997,77 +3031,150 @@ class WebSocketClient:
2997
3031
 
2998
3032
  async def _perform_handshake(self) -> bool:
2999
3033
  """
3000
- SSOT: Perform handshake protocol to get backend-provided thread_id.
3001
- This ensures both CLI and backend agree on the thread_id for proper event routing.
3034
+ Wait for proactive handshake from server (as of 2025-10-09).
3002
3035
 
3003
- Protocol:
3004
- 1. Client sends handshake_request with client info
3005
- 2. Backend responds with handshake_response containing thread_id and session info
3006
- 3. Client extracts thread_id and sends session_acknowledged
3036
+ Server Phase Alignment:
3037
+ - INITIALIZING: WebSocket connection accepted
3038
+ - AUTHENTICATING: User validation (happens during connect())
3039
+ - HANDSHAKING: Server proactively sends handshake_response (we wait here)
3040
+ - READY: Services initialized
3041
+ - PROCESSING: Message handling begins
3007
3042
 
3008
- The handshake_response is distinct from connection_established to avoid
3009
- race conditions and clearly separate WebSocket connection from session handshake.
3043
+ The client waits during server's HANDSHAKING phase to receive the
3044
+ proactive handshake_response without sending any request.
3010
3045
  """
3011
3046
  try:
3012
3047
  import asyncio
3013
3048
 
3014
3049
  self.debug.debug_print(
3015
- "Starting handshake...",
3050
+ "Waiting for server to enter HANDSHAKING phase and send handshake...",
3016
3051
  DebugLevel.VERBOSE,
3017
3052
  style="cyan"
3018
3053
  )
3019
3054
 
3020
- # Try to receive immediately (backend may send immediately)
3055
+ # Server enters HANDSHAKING phase after authentication
3056
+ # and proactively sends handshake_response
3057
+ # Use configured timeout or default to 10 seconds
3058
+ handshake_timeout = self.handshake_timeout if hasattr(self, 'handshake_timeout') and self.handshake_timeout else 10.0
3059
+ start_time = asyncio.get_event_loop().time()
3060
+
3021
3061
  try:
3022
- # Non-blocking check if backend sent handshake_response immediately
3023
- # Use configurable timeout (default 2.0 seconds for initial check)
3024
- initial_timeout = min(2.0, self.handshake_timeout / 6)
3025
- response_msg = await asyncio.wait_for(self.ws.recv(), timeout=initial_timeout)
3026
- response = json.loads(response_msg)
3027
-
3028
- # SSOT: Process any handshake-related response
3029
- return await self._process_any_handshake_response(response)
3030
- except asyncio.TimeoutError:
3031
- # Backend didn't send immediately, try sending a trigger message
3032
- pass # Silent - this is normal for backends that wait for trigger
3033
-
3034
- # If we didn't get handshake_response immediately, send a trigger
3035
- # This handles backends that wait for an initial message
3036
- trigger_message = {
3037
- "type": "handshake_request",
3038
- "client_type": "cli",
3039
- "client_version": "2.0.0",
3040
- "timestamp": datetime.now(timezone.utc).isoformat()
3041
- }
3062
+ while (asyncio.get_event_loop().time() - start_time) < handshake_timeout:
3063
+ remaining_time = handshake_timeout - (asyncio.get_event_loop().time() - start_time)
3042
3064
 
3043
- self.debug.debug_print(
3044
- "Sending handshake_request...",
3045
- DebugLevel.VERBOSE,
3046
- style="cyan"
3047
- )
3065
+ # Be ready to receive messages during server's HANDSHAKING phase
3066
+ self.debug.debug_print(
3067
+ f"Listening for handshake (remaining: {remaining_time:.1f}s)...",
3068
+ DebugLevel.VERBOSE,
3069
+ style="dim"
3070
+ )
3048
3071
 
3049
- await self.ws.send(json.dumps(trigger_message))
3072
+ try:
3073
+ # Use the full remaining time for recv to avoid timeout errors
3074
+ # This prevents premature connection closure during handshake
3075
+ response_msg = await asyncio.wait_for(
3076
+ self.ws.recv(),
3077
+ timeout=remaining_time
3078
+ )
3079
+ response = json.loads(response_msg)
3080
+ except asyncio.TimeoutError:
3081
+ # Gracefully handle timeout - don't let it propagate and close connection
3082
+ self.debug.debug_print(
3083
+ "Handshake wait timed out after 10 seconds",
3084
+ DebugLevel.VERBOSE,
3085
+ style="yellow"
3086
+ )
3087
+ break # Exit loop to handle timeout below
3088
+ except json.JSONDecodeError as e:
3089
+ # Handle JSON parsing errors gracefully
3090
+ self.debug.debug_print(
3091
+ f"Invalid JSON received during handshake: {e}",
3092
+ DebugLevel.VERBOSE,
3093
+ style="yellow"
3094
+ )
3095
+ continue # Try to receive next message
3096
+ except websockets.exceptions.ConnectionClosed as e:
3097
+ # Connection closed during handshake - this is the issue!
3098
+ self.debug.debug_print(
3099
+ f"Connection closed during handshake wait: {e}",
3100
+ DebugLevel.BASIC,
3101
+ style="red"
3102
+ )
3103
+ return False # Connection lost, can't continue
3050
3104
 
3051
- # Now wait for the response
3052
- try:
3053
- # Use configurable handshake timeout for main wait
3054
- response_msg = await asyncio.wait_for(self.ws.recv(), timeout=self.handshake_timeout)
3055
- response = json.loads(response_msg)
3105
+ msg_type = response.get('type', 'unknown')
3106
+ self.debug.debug_print(
3107
+ f"Received: {msg_type}",
3108
+ DebugLevel.VERBOSE,
3109
+ style="cyan"
3110
+ )
3111
+
3112
+ # Check for the proactive handshake_response
3113
+ if msg_type == 'handshake_response':
3114
+ # Server is in HANDSHAKING phase and sent the handshake
3115
+ self.debug.debug_print(
3116
+ "✅ Server sent proactive handshake (HANDSHAKING phase)",
3117
+ DebugLevel.VERBOSE,
3118
+ style="green"
3119
+ )
3056
3120
 
3057
- # SSOT: Process any handshake-related response
3058
- return await self._process_any_handshake_response(response)
3121
+ # Process the handshake
3122
+ result = await self._process_handshake_response(response)
3123
+
3124
+ if result:
3125
+ self.debug.debug_print(
3126
+ f"Handshake complete - Thread ID: {self.current_thread_id}",
3127
+ DebugLevel.BASIC,
3128
+ style="green"
3129
+ )
3130
+ self.debug.debug_print(
3131
+ "Server phases: AUTH ✓ → HANDSHAKING ✓ → READY",
3132
+ DebugLevel.VERBOSE,
3133
+ style="green"
3134
+ )
3135
+
3136
+ return result
3137
+
3138
+ # Handle other message types while waiting
3139
+ elif msg_type == 'connection_established':
3140
+ # This is from AUTHENTICATING phase completion
3141
+ self.debug.debug_print(
3142
+ "Connection established (AUTH phase) - waiting for HANDSHAKING phase",
3143
+ DebugLevel.VERBOSE,
3144
+ style="yellow"
3145
+ )
3146
+ # Store the event but continue waiting
3147
+ if hasattr(self, 'events'):
3148
+ self.events.append(WebSocketEvent.from_dict(response))
3149
+
3150
+ else:
3151
+ # Other event types - store but keep waiting for handshake
3152
+ self.debug.debug_print(
3153
+ f"Storing {msg_type} event - still waiting for handshake",
3154
+ DebugLevel.VERBOSE,
3155
+ style="dim"
3156
+ )
3157
+ if hasattr(self, 'events'):
3158
+ self.events.append(WebSocketEvent.from_dict(response))
3059
3159
 
3060
3160
  except asyncio.TimeoutError:
3061
- # Timeout - log error concisely
3062
- self.debug.debug_print(
3063
- "ERROR: Handshake timeout - no response from backend",
3064
- DebugLevel.BASIC,
3065
- style="red"
3066
- )
3067
- return False
3161
+ pass # Fall through to timeout handling below
3162
+
3163
+ # Timeout - server didn't send handshake
3164
+ self.debug.debug_print(
3165
+ "ERROR: Server did not send handshake within 10 seconds",
3166
+ DebugLevel.BASIC,
3167
+ style="red"
3168
+ )
3169
+ self.debug.debug_print(
3170
+ "Server may not have HANDSHAKING phase (pre-2025-10-09 version)",
3171
+ DebugLevel.BASIC,
3172
+ style="yellow"
3173
+ )
3174
+ return False
3068
3175
 
3069
3176
  except Exception as e:
3070
- # Handshake error - log but don't completely fail
3177
+ # Handshake error
3071
3178
  error_msg = f"WARNING: Handshake error: {e}"
3072
3179
  self.debug.log_error(e, "handshake protocol")
3073
3180
  self.debug.debug_print(error_msg, DebugLevel.BASIC, style="yellow")
@@ -3100,51 +3207,29 @@ class WebSocketClient:
3100
3207
  return await self._process_handshake_response(response)
3101
3208
 
3102
3209
  elif response_type == 'connection_established':
3103
- # Backend sends connection_established with connection_id as thread identifier
3210
+ # connection_established is NOT a handshake response!
3211
+ # It's just a WebSocket connection event. We should NOT acknowledge it.
3212
+ # We need to wait for the actual handshake_response message.
3104
3213
  self.debug.debug_print(
3105
- "Processing connection_established as handshake response",
3214
+ "Received connection_established - waiting for handshake_response",
3106
3215
  DebugLevel.VERBOSE,
3107
- style="green"
3216
+ style="yellow"
3108
3217
  )
3109
3218
 
3110
- # Extract connection_id from the data field and use as thread_id
3219
+ # Extract connection_id for logging purposes only
3111
3220
  connection_data = response.get('data', {})
3112
3221
  connection_id = connection_data.get('connection_id')
3113
3222
 
3114
- if not connection_id:
3223
+ if connection_id:
3115
3224
  self.debug.debug_print(
3116
- "ERROR: connection_established missing connection_id",
3117
- DebugLevel.BASIC,
3118
- style="red"
3225
+ f"Connection established with connection_id: {connection_id} (not using as thread_id)",
3226
+ DebugLevel.VERBOSE,
3227
+ style="yellow"
3119
3228
  )
3120
- return False
3121
-
3122
- # Use connection_id as thread_id (SSOT for thread identification)
3123
- self.current_thread_id = connection_id
3124
- self.run_id = connection_id # Also use as run_id
3125
- self._update_thread_cache(connection_id)
3126
-
3127
- self.debug.debug_print(
3128
- f"Handshake complete - Using connection_id as thread ID: {connection_id}",
3129
- DebugLevel.VERBOSE,
3130
- style="green"
3131
- )
3132
-
3133
- # Send acknowledgment with the connection_id
3134
- ack_message = {
3135
- "type": "session_acknowledged",
3136
- "thread_id": connection_id,
3137
- "timestamp": datetime.now(timezone.utc).isoformat()
3138
- }
3139
3229
 
3140
- await self.ws.send(json.dumps(ack_message))
3141
- self.debug.debug_print(
3142
- f"Sent session_acknowledged with thread_id: {connection_id}",
3143
- DebugLevel.VERBOSE,
3144
- style="cyan"
3145
- )
3146
-
3147
- return True
3230
+ # Return False to indicate we're still waiting for handshake_response
3231
+ # DO NOT send session_acknowledged here!
3232
+ return False
3148
3233
 
3149
3234
  else:
3150
3235
  # Unexpected response type
@@ -3217,14 +3302,64 @@ class WebSocketClient:
3217
3302
  )
3218
3303
 
3219
3304
  # CRITICAL: Send acknowledgment with the SAME thread_id
3305
+ # Per WebSocket Client Lifecycle Guide, use "handshake_acknowledged"
3220
3306
  ack_message = {
3221
- "type": "session_acknowledged",
3307
+ "type": "handshake_acknowledged",
3222
3308
  "thread_id": backend_thread_id, # Echo back the same ID
3223
3309
  "timestamp": datetime.now(timezone.utc).isoformat()
3224
3310
  }
3225
3311
 
3226
3312
  await self.ws.send(json.dumps(ack_message))
3227
3313
 
3314
+ # Per WebSocket Client Lifecycle Guide, wait for handshake_complete
3315
+ # and then add delay for server to enter Phase 5 (Processing)
3316
+ self.debug.debug_print(
3317
+ "Waiting for handshake_complete confirmation...",
3318
+ DebugLevel.VERBOSE,
3319
+ style="cyan"
3320
+ )
3321
+
3322
+ # Wait briefly for handshake_complete message
3323
+ try:
3324
+ complete_msg = await asyncio.wait_for(self.ws.recv(), timeout=2.0)
3325
+ complete_data = json.loads(complete_msg)
3326
+ if complete_data.get('type') == 'handshake_complete':
3327
+ self.debug.debug_print(
3328
+ "✅ Received handshake_complete - Server at Phase 4 (Ready)",
3329
+ DebugLevel.VERBOSE,
3330
+ style="green"
3331
+ )
3332
+
3333
+ # CRITICAL: Add delay for server to enter Phase 5 (Processing)
3334
+ # Per documentation, 500ms is recommended
3335
+ self.debug.debug_print(
3336
+ "Waiting 500ms for server to enter Phase 5 (Processing)...",
3337
+ DebugLevel.VERBOSE,
3338
+ style="cyan"
3339
+ )
3340
+ await asyncio.sleep(0.5)
3341
+
3342
+ self.debug.debug_print(
3343
+ "✅ Server should now be in Phase 5 (Processing) - ready for messages",
3344
+ DebugLevel.VERBOSE,
3345
+ style="green"
3346
+ )
3347
+ except asyncio.TimeoutError:
3348
+ # If no handshake_complete, still add a delay to be safe
3349
+ self.debug.debug_print(
3350
+ "No handshake_complete received, adding safety delay",
3351
+ DebugLevel.VERBOSE,
3352
+ style="yellow"
3353
+ )
3354
+ await asyncio.sleep(0.5)
3355
+ except Exception as e:
3356
+ self.debug.debug_print(
3357
+ f"Error waiting for handshake_complete: {e}",
3358
+ DebugLevel.VERBOSE,
3359
+ style="yellow"
3360
+ )
3361
+ await asyncio.sleep(0.5)
3362
+
3228
3363
  return True
3229
3364
 
3230
3365
  def _get_platform_cache_path(self) -> Path:
@@ -3565,6 +3700,27 @@ class WebSocketClient:
3565
3700
  if not self.ws:
3566
3701
  raise RuntimeError("WebSocket not connected")
3567
3702
 
3703
+ # Check if connection handshake is fully complete
3704
+ if not self.connected:
3705
+ self.debug.debug_print(
3706
+ "ERROR: Connection not ready - handshake not complete",
3707
+ DebugLevel.BASIC,
3708
+ style="red"
3709
+ )
3710
+ safe_console_print(
3711
+ "\n❌ ERROR: Cannot send message - server not ready",
3712
+ style="red",
3713
+ json_mode=self.config.json_mode,
3714
+ ci_mode=self.config.ci_mode
3715
+ )
3716
+ safe_console_print(
3717
+ "The server is still completing its initialization phases.",
3718
+ style="yellow",
3719
+ json_mode=self.config.json_mode,
3720
+ ci_mode=self.config.ci_mode
3721
+ )
3722
+ raise RuntimeError("Connection not ready - handshake incomplete. Server needs to reach Processing phase.")
3723
+
3568
3724
  # Generate run_id
3569
3725
  self.run_id = f"cli_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{os.getpid()}"
3570
3726
 
@@ -3810,9 +3966,9 @@ class WebSocketClient:
3810
3966
  # Check if payload exceeds maximum allowed size
3811
3967
  if payload_size_mb > MAX_SIZE_MB:
3812
3968
  error_msg = f"""
3813
- ╔══════════════════════════════════════════════════════════════════════════════╗
3814
- ❌ PAYLOAD SIZE EXCEEDED
3815
- ╔══════════════════════════════════════════════════════════════════════════════╗
3969
+ +==============================================================================+
3970
+ | ❌ PAYLOAD SIZE EXCEEDED |
3971
+ +==============================================================================+
3816
3972
 
3817
3973
  Payload Size: {payload_size_mb:.2f} MB
3818
3974
  Maximum Allowed: {MAX_SIZE_MB:.1f} MB
@@ -3840,7 +3996,7 @@ class WebSocketClient:
3840
3996
 
3841
3997
  ✨ OPTIMAL PERFORMANCE: Keep payload under {OPTIMAL_SIZE_MB:.1f} MB for best results
3842
3998
 
3843
- ╚══════════════════════════════════════════════════════════════════════════════╝
3999
+ +==============================================================================+
3844
4000
  """
3845
4001
  safe_console_print(error_msg, style="red")
3846
4002
  raise RuntimeError(f"Payload size ({payload_size_mb:.2f} MB) exceeds maximum allowed ({MAX_SIZE_MB:.1f} MB)")
@@ -3848,9 +4004,9 @@ class WebSocketClient:
3848
4004
  # Warn if payload is large but within limits
3849
4005
  if payload_size_mb > WARNING_SIZE_MB:
3850
4006
  warning_msg = f"""
3851
- ╔══════════════════════════════════════════════════════════════════════════════╗
3852
- ⚠️ LARGE PAYLOAD WARNING
3853
- ╔══════════════════════════════════════════════════════════════════════════════╗
4007
+ +==============================================================================+
4008
+ | ⚠️ LARGE PAYLOAD WARNING |
4009
+ +==============================================================================+
3854
4010
 
3855
4011
  Payload Size: {payload_size_mb:.2f} MB
3856
4012
  Maximum Allowed: {MAX_SIZE_MB:.1f} MB
@@ -3875,7 +4031,7 @@ class WebSocketClient:
3875
4031
  • Keep total payload under {OPTIMAL_SIZE_MB:.1f} MB for best results
3876
4032
  • Larger payloads may take longer to process
3877
4033
 
3878
- ╚══════════════════════════════════════════════════════════════════════════════╝
4034
+ +==============================================================================+
3879
4035
  """
3880
4036
  safe_console_print(warning_msg, style="yellow")
3881
4037
 
@@ -3910,7 +4066,16 @@ class WebSocketClient:
3910
4066
  type=data.get('type', 'unknown'),
3911
4067
  data=data
3912
4068
  )
3913
- self.events.append(event)
4069
+ # Skip duplicate connection_established events after handshake
4070
+ if event.type == 'connection_established' and self.connected and self.current_thread_id:
4071
+ self.debug.debug_print(
4072
+ "Ignoring duplicate connection_established (already connected with thread_id)",
4073
+ DebugLevel.VERBOSE,
4074
+ style="yellow"
4075
+ )
4076
+ # Don't append duplicate connection events
4077
+ else:
4078
+ self.events.append(event)
3914
4079
 
3915
4080
  # Handle connection_established as a basic WebSocket connection event
3916
4081
  # Note: handshake_response is now used for thread_id exchange, not connection_established
@@ -4781,51 +4946,47 @@ class AgentCLI:
4781
4946
 
4782
4947
  async def _receive_events(self):
4783
4948
  """Background task to receive and display events"""
4784
- thinking_spinner = None
4785
- thinking_live = None
4949
+ # Create persistent spinner that stays at bottom
4950
+ thinking_spinner = Progress(
4951
+ SpinnerColumn(spinner_name="dots"),
4952
+ TextColumn("[dim]{task.description}"),
4953
+ console=Console(file=sys.stderr),
4954
+ transient=True
4955
+ )
4956
+ thinking_live = Live(thinking_spinner, console=Console(file=sys.stderr), refresh_per_second=10)
4957
+ thinking_task = None
4786
4958
 
4787
- async def handle_event(event: WebSocketEvent):
4788
- nonlocal thinking_spinner, thinking_live
4959
+ # Start the spinner live display
4960
+ thinking_live.start()
4789
4961
 
4790
- # Stop spinner if it's running and we get any non-thinking/non-executing event
4791
- if thinking_live and event.type not in ["agent_thinking", "tool_executing"]:
4792
- thinking_live.stop()
4793
- thinking_live = None
4794
- thinking_spinner = None
4962
+ async def handle_event(event: WebSocketEvent):
4963
+ nonlocal thinking_task
4795
4964
 
4796
4965
  # Display event with enhanced formatting
4797
4966
  formatted_event = event.format_for_display(self.debug)
4798
4967
  safe_console_print(f"[{event.timestamp.strftime('%H:%M:%S')}] {formatted_event}")
4799
4968
 
4800
- # Start spinner for agent_thinking events (20-60 second wait indicator)
4801
- if event.type == "agent_thinking" and not thinking_live:
4802
- thought = event.data.get('thought', event.data.get('reasoning', ''))
4803
- spinner_text = truncate_with_ellipsis(thought, 60) if thought else "Processing..."
4969
+ # Update spinner for thinking and tool_executing events
4970
+ if event.type in ["agent_thinking", "tool_executing"]:
4971
+ # Remove old task if exists
4972
+ if thinking_task is not None:
4973
+ thinking_spinner.remove_task(thinking_task)
4974
+ thinking_task = None
4804
4975
 
4805
- thinking_spinner = Progress(
4806
- SpinnerColumn(spinner_name="dots"),
4807
- TextColumn("[cyan]{task.description}"),
4808
- console=Console(file=sys.stderr),
4809
- transient=True
4810
- )
4811
- thinking_live = Live(thinking_spinner, console=Console(file=sys.stderr), refresh_per_second=10)
4812
- thinking_live.start()
4813
- thinking_spinner.add_task(f"💭 {spinner_text}", total=None)
4814
-
4815
- # Start spinner for tool_executing events
4816
- elif event.type == "tool_executing" and not thinking_live:
4817
- tool_name = event.data.get('tool', event.data.get('tool_name', 'Unknown'))
4818
- spinner_text = f"Executing {tool_name}..."
4819
-
4820
- thinking_spinner = Progress(
4821
- SpinnerColumn(spinner_name="dots"),
4822
- TextColumn("[blue]{task.description}"),
4823
- console=Console(file=sys.stderr),
4824
- transient=True
4825
- )
4826
- thinking_live = Live(thinking_spinner, console=Console(file=sys.stderr), refresh_per_second=10)
4827
- thinking_live.start()
4828
- thinking_spinner.add_task(f"🔧 {spinner_text}", total=None)
4976
+ # Add new task with latest event
4977
+ if event.type == "agent_thinking":
4978
+ thought = event.data.get('thought', event.data.get('reasoning', ''))
4979
+ spinner_text = truncate_with_ellipsis(thought, 60) if thought else "Processing..."
4980
+ thinking_task = thinking_spinner.add_task(f"💭 {spinner_text}", total=None)
4981
+ elif event.type == "tool_executing":
4982
+ tool_name = event.data.get('tool', event.data.get('tool_name', 'Unknown'))
4983
+ spinner_text = f"Executing {tool_name}..."
4984
+ thinking_task = thinking_spinner.add_task(f"🔧 {spinner_text}", total=None)
4985
+
4986
+ # Clear spinner for any other event type
4987
+ elif thinking_task is not None:
4988
+ thinking_spinner.remove_task(thinking_task)
4989
+ thinking_task = None
4829
4990
 
4830
4991
  # Display raw data in verbose mode
4831
4992
  if self.debug.debug_level >= DebugLevel.DIAGNOSTIC:
@@ -4838,58 +4999,53 @@ class AgentCLI:
4838
4999
  try:
4839
5000
  await self.ws_client.receive_events(callback=handle_event)
4840
5001
  finally:
4841
- # Clean up spinner if it's still running
4842
- if thinking_live:
4843
- thinking_live.stop()
5002
+ # Clean up spinner
5003
+ thinking_live.stop()
4844
5004
 
4845
5005
  async def _receive_events_with_display(self):
4846
5006
  """ISSUE #1603 FIX: Enhanced event receiver with better display for single message mode"""
4847
- thinking_spinner = None
4848
- thinking_live = None
5007
+ # Create persistent spinner that stays at bottom
5008
+ thinking_spinner = Progress(
5009
+ SpinnerColumn(spinner_name="dots"),
5010
+ TextColumn("[dim]{task.description}"),
5011
+ console=Console(file=sys.stderr),
5012
+ transient=True
5013
+ )
5014
+ thinking_live = Live(thinking_spinner, console=Console(file=sys.stderr), refresh_per_second=10)
5015
+ thinking_task = None
4849
5016
 
4850
- async def handle_event_with_display(event: WebSocketEvent):
4851
- nonlocal thinking_spinner, thinking_live
5017
+ # Start the spinner live display
5018
+ thinking_live.start()
4852
5019
 
4853
- # Stop spinner if it's running and we get any non-thinking/non-executing event
4854
- if thinking_live and event.type not in ["agent_thinking", "tool_executing"]:
4855
- thinking_live.stop()
4856
- thinking_live = None
4857
- thinking_spinner = None
5020
+ async def handle_event_with_display(event: WebSocketEvent):
5021
+ nonlocal thinking_task
4858
5022
 
4859
5023
  # Display event with enhanced formatting and emojis
4860
5024
  formatted_event = event.format_for_display(self.debug)
4861
5025
  timestamp = event.timestamp.strftime('%H:%M:%S')
4862
5026
  safe_console_print(f"[{timestamp}] {formatted_event}")
4863
5027
 
4864
- # Start spinner for agent_thinking events (20-60 second wait indicator)
4865
- if event.type == "agent_thinking" and not thinking_live:
4866
- thought = event.data.get('thought', event.data.get('reasoning', ''))
4867
- spinner_text = truncate_with_ellipsis(thought, 60) if thought else "Processing..."
5028
+ # Update spinner for thinking and tool_executing events
5029
+ if event.type in ["agent_thinking", "tool_executing"]:
5030
+ # Remove old task if exists
5031
+ if thinking_task is not None:
5032
+ thinking_spinner.remove_task(thinking_task)
5033
+ thinking_task = None
4868
5034
 
4869
- thinking_spinner = Progress(
4870
- SpinnerColumn(spinner_name="dots"),
4871
- TextColumn("[cyan]{task.description}"),
4872
- console=Console(file=sys.stderr),
4873
- transient=True
4874
- )
4875
- thinking_live = Live(thinking_spinner, console=Console(file=sys.stderr), refresh_per_second=10)
4876
- thinking_live.start()
4877
- thinking_spinner.add_task(f"💭 {spinner_text}", total=None)
4878
-
4879
- # Start spinner for tool_executing events
4880
- elif event.type == "tool_executing" and not thinking_live:
4881
- tool_name = event.data.get('tool', event.data.get('tool_name', 'Unknown'))
4882
- spinner_text = f"Executing {tool_name}..."
4883
-
4884
- thinking_spinner = Progress(
4885
- SpinnerColumn(spinner_name="dots"),
4886
- TextColumn("[blue]{task.description}"),
4887
- console=Console(file=sys.stderr),
4888
- transient=True
4889
- )
4890
- thinking_live = Live(thinking_spinner, console=Console(file=sys.stderr), refresh_per_second=10)
4891
- thinking_live.start()
4892
- thinking_spinner.add_task(f"🔧 {spinner_text}", total=None)
5035
+ # Add new task with latest event
5036
+ if event.type == "agent_thinking":
5037
+ thought = event.data.get('thought', event.data.get('reasoning', ''))
5038
+ spinner_text = truncate_with_ellipsis(thought, 60) if thought else "Processing..."
5039
+ thinking_task = thinking_spinner.add_task(f"💭 {spinner_text}", total=None)
5040
+ elif event.type == "tool_executing":
5041
+ tool_name = event.data.get('tool', event.data.get('tool_name', 'Unknown'))
5042
+ spinner_text = f"Executing {tool_name}..."
5043
+ thinking_task = thinking_spinner.add_task(f"🔧 {spinner_text}", total=None)
5044
+
5045
+ # Clear spinner for any other event type
5046
+ elif thinking_task is not None:
5047
+ thinking_spinner.remove_task(thinking_task)
5048
+ thinking_task = None
4893
5049
 
4894
5050
  # Issue #2177: WebSocket event validation
4895
5051
  if self.validate_events and self.event_validator:
@@ -4969,9 +5125,8 @@ class AgentCLI:
4969
5125
  try:
4970
5126
  await self.ws_client.receive_events(callback=handle_event_with_display)
4971
5127
  finally:
4972
- # Clean up spinner if it's still running
4973
- if thinking_live:
4974
- thinking_live.stop()
5128
+ # Clean up spinner
5129
+ thinking_live.stop()
4975
5130
 
4976
5131
  def _get_event_summary(self, event: WebSocketEvent) -> str:
4977
5132
  """ISSUE #1603 FIX: Get a concise summary of an event for display"""